docs(adr): ADR 009 makes every operator edge network-transparent, over up to two hops and request/response transports (improvise-felo.4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (adr9-steward)
2026-10-09 21:18:55 -07:00
committed by Edward Langley
parent ee09edee0e
commit f3a54d34e0
+37 -19
View File
@@ -180,8 +180,34 @@ in their last bits, because they become correctly rounded, and a sum of `-0.0` r
### 9. Delivery
The same circuit serves the in-process TUI and remote clients. Its internal streams never
cross a wire, because aggregation stays on the host.
**Every edge may cross a network.** An edge, meaning the stream from one operator to the
next, may be in-process or remote, and a path may cross up to two network hops: browser ⇄
gateway ⇄ host, or host ⇄ worker ⇄ client. No edge assumes a function call or shared
memory. An operator that sits at a wire boundary is just one implementation of an edge,
an **edge adapter**. So every edge has the same semantics:
- **A wire form.** Every element type that flows on an edge has a serializable form,
defined in `improvise-protocol`. The adapter converts to and from it, and `zset` stays
free of serde. `CellKey` is not `Serialize`, so on the wire a cell is named by its
tuple of ids, its position or an ADR 002 surrogate.
- **Stamps compose end to end.** The host stamps each published batch (below), and an
intermediate edge forwards the stamp unchanged. So a loss anywhere on the path shows up
downstream as a gap.
- **Ordering and backpressure.** An edge delivers in order. A slow edge coalesces
exactly: deltas are summed, and notices merged.
- **No long-lived stream assumed.** An edge may be carried as a series of requests and
responses, for example several HTTP requests on one connection, as well as a socket.
So each edge keeps an ordered log, every request can be retried, and a resync from a
publication stamp is idempotent.
- **Failure is local to a hop.** A hop may be a socket or a series of requests, and it is
alive while its next message or request succeeds. Each hop keeps alive in both
directions. A side that hears nothing within the timeout, or whose request fails,
treats the hop as dropped. The downstream side then resyncs: it refetches through the
path, and the host answers from current state.
Aggregation stays on the model side (ADR 008), so the operators that aggregate run on
the host. Which other edges cross a wire is a deployment choice. The engine's
invalidation signal, which ADR 006's replay trigger relies on, is the change notice and
the refetch rule below, followed from a displayed cell's key back to the model.
**A request** is a shape key plus a window, given as an absolute cross-product top-left
and dimensions. The window is never state, and a request costs O(visible).
@@ -227,11 +253,12 @@ identity, so every NaN counts as one value, because the wire spells every NaN `"
state. `GridExtent` is derived by the client, and in records mode the record count is
fetched.
**A connection operator** at the wire boundary adapts the circuit to a connection. It is
a sink for results and notices and a source of requests toward the circuit, and the
mirror image toward the client. It owns serialization, the stamp rules, notice
coalescing, keep-alive in both directions, and session resumption. In process it is
function calls. Its shape is open question 4.
**Edge adapters at the client.** Toward the circuit, a client's adapter is a sink for
results and notices and a source of requests. Toward the client it is the mirror image.
Each hop has an adapter at each end, and each end keeps only that hop's state: the last
stamp it applied and its liveness timer. A gateway hop (browser ⇄ gateway ⇄ host) may
serve many clients behind one upstream connection, so the host sends one notice per
gateway rather than one per client. In process, an edge is a function call.
### 10. Text column widths
@@ -290,7 +317,7 @@ Step 8's required tests:
- a dropped notice is detected;
- a reconnect after a host restart refetches;
- a refused frame consumes no publication number;
- a dropped connection forces a refetch.
- a dropped hop forces a refetch.
## Open questions
@@ -299,7 +326,7 @@ Step 8's required tests:
O(clients × all moved keys) per batch. Filtering by topic would send each connection
only its own shape's key lists. It needs one subscription per connection, which pins
its shape in the cache. Is such a subscription "model-relevant information" the
backend may hold? It could also live in the connection operator (question 4).
backend may hold? On a two-hop path the topics could instead live in a gateway.
*Recommendation:* broadcast with the size cap. Add topics only if measured traffic
with many clients on different shapes justifies them.
@@ -318,16 +345,7 @@ Step 8's required tests:
small from-scratch reference implementation, kept in the test crate, as the oracle,
and a pushdown backend behind the same trait for query-only sources.
4. **What shape is the connection operator?** Either one component per connection, or
one per host process: a host half shared by all its connections, with a client half
in every front end. The second lets the host send one notice per operator rather than
per client, but holds per-connection state in the shared operator. Who owns
resumption state, and is it backend state? *Recommendation:* one per host process,
with resumption state in the host half keyed by a client-held token, treated as
delivery state rather than UI state. In the TUI and the browser worker, with one
client each, it is a function call.
5. **Which columns get a host-maintained width?** Columns whose cells are text? What
4. **Which columns get a host-maintained width?** Columns whose cells are text? What
about a column holding both text and numbers? *Recommendation:* text cells get the
maintained width, and the view layer takes the larger of that and its own numeric
width.