Files
2026-10-10 21:55:30 -07:00

264 lines
18 KiB
Markdown

# Delivery: the reasoning
ADR 009 step 8 (improvise-z2vr): notices per committed command, watermarks, corrections, the shared
bounded log, the lossless wire and the edge adapters. The decisions are the ADR's clause 9
(`context/adr/009-incremental-evaluation.md`). This file records how the code reads them, the places
the ADR left a choice open, and the places it could not be satisfied yet. The evidence for each test is
in `context/test-evidence/delivery.md`.
**What is built, and against what.** Neither the incremental backend nor ADR 008's render model
exists. So the source of every target is the existing Eager path: the cells a `GridLayout` draws for a
view. Everything below is correct against that source and does not depend on what replaces it. The
circuit will supply "moved output keys" where `Delivery::commit` diffs two `Output`s today, and
nothing else changes.
| piece | where |
|---|---|
| wire values, keys, Z-set elements | `improvise-protocol/src/delivery/wire.rs` |
| stamp, range, notice, merge | `.../notice.rs` |
| request, correction, `Edge` | `.../exchange.rs` |
| the client end: watermark, held cells, when to ask | `.../client.rs` |
| the publisher, the log, live shapes, answering | `improvise-command/src/ui/delivery.rs` |
| `Host` integration, in-process and JSON edges | `ui/host.rs`, `ui/delivery.rs` |
## 1. The wire form
A number crosses as ADR 005's canonical text through `numtower::Num`: `#i0.1`, `#i-0.0`, `inf`,
`-inf`, `nan`. `WireNum` accepts only text the tower itself writes (`parse` re-prints and compares),
because a second spelling of one value would make textual equality wrong. Equality of wire values is
textual, so `-0.0` and `+0.0` are different wire values. That is the opposite of the cell delta in
clause 3, which makes them equal; the two do not meet, because the delta is a store-side signal and a
correction is a value the client displays.
An integer is a JSON number below 2^53 and a decimal string at or above it (`wide`, `wide_weight`).
That covers stamps, positions, window sizes and weights. Text is a JSON string, byte-exact.
`CellValue` is still an `f64`, so `WireValue::of` writes it through `Num::inexact` and
`to_cell_value` narrows an exact number to `f64`. The round-trip properties therefore run over
28-digit decimals and `i128`-wide fractions at the `WireNum` level, where they are lossless, and over
`f64` at the `CellValue` level, where only the NaN payload is dropped.
`CellKey` is not `Serialize`, so a cell is named by `WireKey`, its `(category, item)` tuple. Under
ADR 007 those become ids; nothing here reads the strings.
## 2. Stamps and notices
`Stamp` is `(host instance, document_generation, publication)`. A stamp from another host or
generation is behind every current one (`Stamp::is_behind`). The publication number increments once
per committed command, and it keeps counting across a document swap rather than restarting, so a
number is never reused within one host instance.
**What "a committed command" is.** The host publishes one notice for each `Command::Model` that
lands. A view command (cursor, mode, a view switch) publishes nothing: it is view-store state and
the model store has no business announcing it. A write that changes nothing still publishes, with
empty `moved`, because the contract is one notice per commit and a client counts them.
**A notice names moved keys per live shape**, plus axis changes, relabels, flags, evicted shapes. Axis
changes are computed from the categories' item lists before and after (`axis_changes`): insert and
remove by item, and a reorder when the surviving items changed order. `Relabel` exists and is never
emitted: with names as identity a rename is a remove and an insert, and display writes arrive with
ADR 007. Flags come from comparing the formula texts, `measure_agg`, the views and the default view.
`Notice::merge` joins two notices only when the second starts where the first ends (same epoch).
Anything else returns both unchanged, so a merge cannot hide a loss. `Moved::Correct` absorbs key
lists in a merge.
## 3. Requests and corrections
A request is a shape key, a window and one of three asks. `Ask::Correction` is "bring me up from my
watermark"; `Ask::Fetch` is "the current values of these keys, covering the publications up to
`through`"; `Ask::Notices` is "the notices after this stamp".
**A correction's elements are `target`, not `target - held`.** The host does not know what the client
holds, so it sends the target restricted as the body says, and the client computes `target - held`
with ordinary Z-set subtraction. A `Moved` body restricts both sides to the named keys, so a key the
target no longer holds is removed. A `Window` body replaces everything held.
**`through` is not `at`.** `at` is the state the values were read at, always the latest committed one.
`through` is how far the correction advances the client's watermark. They differ for a `Fetch`:
the client named the keys of the notices it has seen, and a later commit may have moved other keys
whose notice it has not seen yet. Advancing to `at` would make that notice look already applied, so
the watermark advances only to `through` and the later notice refetches its keys. The values are
at least as new as the watermark claims, never older.
**An axis change makes the correction a window refetch.** A moved key list says which values changed,
not which keys sit in the client's window, and an insert or remove shifts cells into and out of it
without moving them. The ADR has the client re-derive positions from the axis changes and fetch what
it lacks. That needs ADR 008's render model, which this layer does not have. So the client records a
notice that moved an axis or set `flags.views` (`restructured`), asks `Fetch { window: true }`, and the host answers with a
`Window` body whose `carried` notice holds the axis changes, in order. This is a fetch, not a
correction, so the required test "a frame `[AddItem, SetCell]` ... and no client asks for a correction"
holds: it asserts no `Ask::Correction` was sent. When the render model exists the client can narrow
the fetch to the cells that entered its window (improvise-bw4p.9).
A view command (`SetAxis`, `TransposeAxes`, a page selection, a hide) moves positions and no key, and
reaches the client only as `flags.views`. So that flag counts as an axis change on both sides: the client
fetches its window, and the host answers a correction over a range holding the flag with a window body.
## 4. The client
`EdgeClient` holds, per shape: the window, the held cells (a `ZSet`), the **watermark** (applied
through), `seen` (the last notice received), the notices received and not yet fetched, `restructured`
and `stale`.
`on_notice` ignores a duplicate (`to <= seen`), treats a gap, another host or generation, a shape in the
evicted list, or `Moved::Correct` as "ask for a correction", and otherwise queues the moved keys. The
watermark advances over a notice only when nothing is owed, so it never claims a key it has not fetched.
`apply` takes a correction. It is a duplicate if the watermark already covers `through`; a `Moved`
body is a gap unless it starts at or below the watermark. Otherwise `held += target - held`, the
watermark becomes `through`, and the queued notices it covers are dropped. A late reply older than
the watermark therefore cannot regress the state, which is what makes a retry safe.
**A correction from a generation or host the client has left is a duplicate, whatever the
watermark.** A shape records the epochs its watermark has moved out of (`left`, the newest 64), and a
correction from one of them, or from an older generation of the host it is on, changes nothing. Epochs of
different hosts are not ordered, so a client that has never applied anything from the newer host cannot
tell a late reply from a dead one from a legitimate first answer, and applies it; once it has moved on, the
dead host's replies are refused. A duplicate answer also prunes every queued entry from another epoch
and moves `seen` onto the live epoch, so a late notice from a dead host costs one extra round and cannot
livelock `sync`.
**The host that answers a request is live, whatever the client believed.** `left` is a guess, because
epochs of different hosts are unordered: a late reply from a host the client never joined moves it onto
that host, and writes off the live one. Refusing the live host's answers as "left" then strands the client
(`sync` returned `Exhausted`, found by the property test). So `apply_answer`, which `sync` and `poll` use
for the reply to a request they just sent, takes an epoch off `left` before it applies. `apply` is for a
reply that arrives on its own, and keeps the guard. A stale reply can still move the client for one round;
the next request it makes is answered by the live host and brings it back.
**A notice from another epoch only marks the shape stale; `seen` moves only through an answer.** The
first two livelocks (improvise-z2vr) were the same defect reached by different routes, which is why
ignoring one kind of foreign notice after another was not enough. Epochs of different hosts are
unordered, so a notice from an epoch other than `seen`'s cannot be placed. Reading it used to rewind
`seen` into the foreign epoch, and then the live host's own old notices looked new, queued work and set
`restructured`; the answers that followed were covered by the watermark, were duplicates, and never
cleared it. Now such a notice sets `stale` and nothing else, the next request is answered by the live
host, and that answer (`apply_answer`) is what moves `seen`. Two cheaper guards stay: a notice from an
older generation of the host the shape is on, or from an epoch it has left, is ignored without the extra
round; and a duplicate answer clears a `restructured` mark it covers. The last is defensive: no test
reaches it now that `seen` cannot be rewound, and it is kept because a covered answer that cannot clear a mark
it covers is the shape of both livelocks. Merging notices cut for different shapes, or a cut one with an
uncut one, is refused. A notice the client asked for
(`poll`) skips the left-epoch test, since the host that answers is live.
`poll` hands a pulled notice only to the shape it asked about, and the notice says so: `for_shape` sets
`Notice::cut_for`, and `accept` limits any notice that carries it to that shape, however it reaches the
client (`on_notice` included). Without the mark, a pulled notice delivered as if pushed made the other
shape skip keys it never saw.
`connection_lost`, `request_failed` and `unreadable` set `stale`, which `wants` turns into
`Ask::Correction` from the watermark the client held. A duplicate whose `through` equals the watermark
also clears `stale`: the host has said there is nothing newer.
**A lost final notice is invisible to a listener.** The next notice shows the gap, but if none comes the
client never learns. `poll` is the answer: it asks `Ask::Notices { after: seen }`, which the host
answers from the log. The property test ends every history with a `poll` for this reason.
`Event`s (`Axis`, `Relabel`, `Flags`, `Resync`) are what the shape's consumer applies to its
structure. They are deduplicated by `events_through`, because an axis change is not idempotent.
`Resync` means positions are unknown: re-derive them from what is held.
## 5. The Eager stand-in for a shape key
The real key is the circuit's: axes, None categories, filters, pages, `measure_agg`, in canonical id
form. Here a shape key is `eager:view/<name>` (`shape_of_view`) and the output is that view's layout.
That is finer than the real key, which is always safe: two views that would share state do not, and
nothing else is lost. A view that no longer exists is announced as evicted.
`Output` is the shape's whole output, keyed by `WireKey` with the position attached. `target` restricts
it to a window. `moved_since` compares two outputs by value and presence; a changed position is an axis
change, not a key.
**This costs O(grid) per live shape per commit.** It stands in for the circuit's delta, and it is the
reason the module exists beside, not instead of, the incremental work: nothing else in the host reads
the whole grid per command.
## 6. The host
`Delivery` is one value per document, held by `Host`. `Host::handle` calls `Delivery::commit` after
each `Command::Model` that lands, and `Host::deliver` answers a `Request`.
**Reads see only the last committed state.** `answer` reads `Live::output`, which `commit` refreshed,
and never the workbook. A value a failed command wrote is not in it.
**Rollback.** `Host::mutating_frame` clones the `Delivery` beside the app snapshot and the session
mirrors, and `undo_frame` puts it back. The clone is cheap: `Arc` for outputs, notices and structure,
and both the log and the outbox hold at most `capacity` notices. So a refused frame consumes no publication number and publishes nothing.
**The ADR and ADR 001 disagree here, and ADR 001 wins until ADR 006 exists.** Clause 9 says a frame
whose later command fails keeps its earlier commands and their notices, "each command commits on its own
(ADR 006)". Today's host undoes the whole frame (ADR 001 clause 4, `Host::mutating_frame`), and ADR 006 is
not in the tree. The required test "a refused command consumes no publication number, and the commands
before it in its frame stay published" is split in two. The first half is tested. The second is
tested as its opposite, `commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001`,
which is the test to flip when per-command commit lands (improvise-bw4p.8): the publisher already supports it, since it
publishes whatever the host tells it committed.
**Nothing is published while nobody can hear it.** With no live shape and no attached transport
(`Host::attach_notices`), a commit only advances the publication number: no structure is read, nothing is
diffed, the log is cleared and the queued structure is forgotten. A shape that goes live, or a transport
that attaches, re-reads the structure, so its first diff is against a known state. Without this every
host pays for delivery on every command while nothing consumes it. The outbox exists only while a
transport is attached, and keeps the newest `capacity` notices; a transport that falls behind sees a gap
and asks for a correction. The axis diff is linear in the items (`diff_items`, set lookups), and its test
counts comparisons on a counting item type rather than reading a clock.
**The host instance** is a random id, never a time (`Host::new` takes one from `RandomState`).
`RandomState` has no entropy on `wasm32-unknown-unknown`, so a worker must pass a real one through
`Host::with_instance`. A restarted host with the same id would accept a stale watermark as current.
`Worker` still calls `Host::new` (improvise-bw4p.7). Such a restart, with the same instance id and
generation 0, is not recognised as a restart: the client's `older` guard refuses its replies as coming from
an older generation of the host it is on (improvise-bw4p.7).
**Eviction** (`evict_idle`) takes a local count and an idle length in the same unit, never a wall clock
compared across machines. An evicted key is announced in the next notice and forgotten; naming it again
revives it with `since` set to the current publication, so a watermark older than that is answered with
a window.
## 7. Answering
`covered(shape, watermark)` is true when the watermark is in the current epoch, is not ahead of the
host, and either equals the current publication or is no older than both the log's start and the time
the shape went live. Only then are the moved keys of `(watermark, now]` complete.
- covered, only moved keys: a `Moved` body with those keys' current values in the window;
- covered, with axis changes or relabels in range: a `Window` body with `carried`;
- a `Moved::Correct` in range, or not covered: a `Window` body from `None`, which is `target - empty`.
A notice lists no keys for a shape whose list passed `MAX_KEYS`; it says `Moved::Correct` instead, and
costs every reader a correction. `LOG_CAPACITY` bounds the single shared log. Both are set per host by
`limit_delivery` so a test can reach them.
The token in a request is carried and echoed by the client and never read by the host.
## 8. Edge adapters
**Transport contract.** A reply must reach the call that made the request. `Request` and `Correction`
carry no id, so the client cannot tell a late reply from an answer on its own; `sync` and `poll` treat
whatever `Edge::call` returns as the answer (`apply_answer`), and only a reply handed to `apply` is
treated as late. A transport that reorders or duplicates replies must therefore correlate them itself
or drop the strays (improvise-bw4p.10 adds the id to the frames).
`Edge` is one end of a stream: `call(Request) -> Result<Reply, EdgeError>`. `InProcess` is the
function call; `WireEdge` passes request and reply through JSON text, the adapter at a wire boundary,
so the lossless properties are exercised by every test that uses it. Neither adds a `ClientFrame` or
`ServerFrame` variant: the page does not read notices yet, and adding the frames means changing the
browser and the worker together (ADR 008 replaces `Projection`; improvise-bw4p.10). A transport
pushes `Host::take_notices()`.
## Not done, and why
- **The notices are not sent by the socket or worker hosts.** They need frame variants and the page
(above).
- **Positions and column totals are not on the wire.** A correction carries `(key, value)` and no row or
column, and `Output` holds body cells only. The ADR attaches the position when output is sent and lists
totals among the moved keys. Both need the render model and the totals' own keys (ADR 008, clause 10).
- **A twin at the serving end** (a server-side watermark or filter) is not built; the ADR allows it and
does not require it.
- **A pushed filter** is the client's `window`, applied on every request.
- **Hidden-item overlays, pruning queries and column widths** are ADR 008 and clause 10.
**The same edge, generalised in `zset`.** The `zset` crate's edge module, `crates/zset/src/edge.rs` (`context/code/zset.md`, "Edges") carries Z-set deltas between two circuits with the rules of this file (stamps, coalescing by summing, `target - held`, token-matched answers) and no log; this layer is unchanged and does not use it.