docs(agent): add collaborative canvas example motivating DeltaState

This commit is contained in:
Mario Zechner 2026-08-19 17:42:03 +02:00
parent 3be63cebb9
commit 41aea38ffb

View file

@ -1293,3 +1293,145 @@ Review this patch and address all comments:
Comment authors give the sidebar its basic multiplayer presence. A current-viewer roster or cursors would be separate live state and would not be written to the review record.
This is a shared review, not a generic room primitive. Keyed services provide discovery and reactive lifetime; the record repository provides temporary durability; the prompt queue provides idempotent handoff into the session.
## Collaborative canvas: high-frequency replication and the case for `DeltaState`
A shared canvas exercises the corner none of the previous examples touch: no settlement, no quorum — just many users concurrently mutating one document at high frequency, with live cursors on top. Several users drag shapes on the same canvas; every presentation renders every change with low latency; a client joining mid-session sees the current document.
The authority model stays the same: the session facet holds the one authoritative document, applies operations in arrival order, and per-shape last-write-wins falls out of that total order. There is no CRDT, no offline editing, and no mutation replay — those stay out of scope by design. What changes is the replication problem: retransmitting the whole document per drag tick is absurd, so the document must replicate as **one snapshot at join, then deltas**.
```text
CanvasManager singleton — createCanvas()
Canvases[canvasId] keyed instance per canvas
getSnapshot() pull: current document + revision
deltas RemoteEvents — the steady-state feed
presence RemoteState — ephemeral cursors, never durable
applyOps() batched authenticated mutations
```
### Shared contract (what today's primitives allow)
```ts
interface CanvasShape {
shapeId: string;
kind: "rect" | "ellipse" | "path";
x: number;
y: number;
data: JsonValue;
}
interface CanvasSnapshot {
rev: number;
shapes: Record<string, CanvasShape>;
}
type CanvasOp = { type: "upsert"; shape: CanvasShape } | { type: "delete"; shapeId: string };
interface CanvasDelta {
rev: number;
author: string;
ops: CanvasOp[];
}
interface CanvasService {
getSnapshot(context: Context): Promise<CanvasSnapshot>;
readonly deltas: RemoteEvents<CanvasDelta>;
readonly presence: RemoteState<Record<string, { x: number; y: number }>>;
applyOps(ops: CanvasOp[], context: Context): Promise<void>;
moveCursor(position: { x: number; y: number }, context: Context): Promise<void>;
}
const Canvases = defineRemoteService<CanvasService>("canvas");
```
Note what is absent: the document is **not** a `RemoteState`. An eagerly-set `RemoteState<CanvasSnapshot>` would retransmit the full document on every operation, defeating the deltas; a lazily-set one hands late joiners a stale revision whose missing deltas were never replayed — remote events are non-durable, so that gap is permanent. `RemoteState` is the right tool only when retransmit-on-change *is* the sensible protocol (model configuration, progress, attachment status). Documents need a pull method plus a delta feed.
### Session facet: synchronous authority, append-only durability
The facet keeps the authoritative document in memory and mutates it in a synchronous block — validate, apply, stamp the revision, emit the delta — then persists the operation batch to an append-only list (`values.md`):
```ts
async applyOps(ops, context) {
const author = requireClientIdentity(context).clientId;
validateOps(document, ops); // before any mutation
for (const op of ops) applyOp(document, op); // ┐ synchronous block:
document.rev += 1; // │ apply, stamp,
deltas.emit({ rev: document.rev, author, ops }, context); // ┘ publish — no await inside
await tree.appendList(canvasOps(canvasId), { rev: document.rev, ops });
}
```
Because apply-stamp-emit contains no `await`, concurrent calls cannot interleave inside it: revision order equals emission order equals append order. The hot path needs **no critical region** — `appendList` never reads, and there is no read-modify-write window. This mirrors the assistant-frame pattern: synchronous enqueue on the persistence line, no per-write await for throughput. The gate reappears only in compaction (fold the op list into a stored snapshot value, delete the list), and even there rev-fenced folding — replay ignores ops with `rev` at or below the stored snapshot's — makes a crash between the two writes harmless; the region only prevents concurrent compactions. Recovery on activation folds snapshot plus surviving ops and re-spawns the instance.
Presence is the separate-coarse-cell rule verbatim: `moveCursor()` sets one ephemeral `RemoteState` cell where per-client last-write-wins is exactly correct; cursors never enter the op log. Clients coalesce cursor and drag traffic (send at most every few tens of milliseconds, ops batched per call) — every operation is a routed call plus fan-out, and batching is what keeps that affordable.
### The join protocol every author must hand-roll
A presentation joining mid-session must perform, in order:
1. subscribe to `deltas` and buffer incoming events;
2. call `getSnapshot()` and install it;
3. discard buffered deltas with `rev <=` the snapshot's, apply the rest, go live;
4. on a revision gap or reconnect, throw the replica away and start over at step 1.
Every step is mandatory, order-sensitive, and invisible in the type system. Subscribe after pulling and you lose deltas emitted in between; skip the fence and you double-apply; ignore gaps and replicas silently diverge forever. This is the same class of interleaving footgun this document eliminates elsewhere — and it is precisely the guarantee `RemoteState` hydration already implements internally: install a complete snapshot atomically, buffer concurrent updates, deliver snapshot-then-updates with no gap. The only difference is the update format: full values there, deltas here.
### `DeltaState`: the missing primitive
Generalize that hydration machinery instead of hand-rolling it per plugin:
```ts
// contract module — the reducer is shared code imported by both sides, never serialized
const CanvasDoc = defineDeltaState<CanvasSnapshot, CanvasDelta>("canvas-doc", applyDelta);
interface DeltaState<S, D> {
/** `undefined` until hydrated; maintained locally by applying deltas. */
readonly value: S | undefined;
subscribe(listener: (value: S, delta: D | null, context: Context) => void): () => void;
}
interface MutableDeltaState<S, D> extends DeltaState<S, D> {
readonly value: S; // a providing state is always initialized
apply(delta: D, context: Context): void;
}
```
The reducer is a pure, deterministic function in the contract module: it takes a snapshot and a delta and returns a fresh detached snapshot, never throwing on a delta the provider admitted. Deltas carry no revision field — revisions are protocol metadata the host stamps.
Required provider behavior. `apply()` is synchronous: it reduces first (a throwing reducer publishes nothing), then advances the authoritative value, stamps the next revision, and fans the delta frame out to every subscription channel and local listener as one uninterruptible block. Callers may therefore rely on revision order equalling emission order — the canvas facet's synchronous validate-apply-persist sequence depends on it. Hydration needs no locking for the same reason:
```text
onSubscribe(channel):
channel.send({ type: "snapshot", rev, value }) // 1
register channel for future delta frames // 2
→ apply() cannot interleave between 1 and 2, so no delta is missed or duplicated;
the FIFO connection preserves frame order in flight
```
The full snapshot crosses the wire exactly once per subscription and again only on reconnect or detected gap; steady-state traffic is deltas only.
Required consumer behavior, all host-owned: the replica applies the shared reducer locally and fences by revision — frames at or below the replica revision are duplicates and drop; the next consecutive revision applies and notifies; anything later is a gap, which discards the replica and requests a fresh snapshot (deltas racing that snapshot are dropped by the same fence once it installs). Disconnect keeps the last value as stale display data; reconnect rehydrates. Every rule is self-healing toward the provider's authoritative value, and none of them appear in plugin code.
The canvas contract collapses to `readonly document: DeltaState<CanvasSnapshot, CanvasDelta>` — `getSnapshot()`, the join protocol, and the rev fencing all disappear. The session facet also gets its recovery for free, because the reducer folds the durable op log exactly as replicas fold live deltas:
```ts
// activation: rebuild the authoritative value with the same reducer
let snapshot = (await tree.getValue(canvasSnapshotValue(canvasId)))?.value ?? { shapes: {} };
for await (const page of pageList(tree, canvasOps(canvasId))) {
for (const element of page) snapshot = applyDelta(snapshot, element.value);
}
const doc = bindings.deltaState(CanvasDoc, snapshot);
// in the spawned instance:
async applyOps(ops, context) {
const author = requireClientIdentity(context).clientId;
validateOps(doc.value, ops); // against the current authoritative value
const delta: CanvasDelta = { author, ops };
doc.apply(delta, context); // sync: reduce + fan-out
await tree.appendList(canvasOps(canvasId), delta); // durable; call order = revision order
}
```
One pure function now defines the document's semantics in three places — provider `apply()`, every consumer replica, and recovery — which is the payoff for keeping it in the contract module as shared code rather than anything serialized. Note that `apply()` validates nothing: admitting a delta is the provider's decision, made before the call, exactly as with `RemoteState.set()`. A bad admitted delta makes every replica diverge identically and any resync converges them back to the provider.
This is not a new idea in this document — it is the transcript-streaming prescription ("semantic deltas plus a final authoritative replacement") promoted from a protocol footnote to a plugin-visible primitive. Three examples already need it: transcript streaming, the survey draft board, and this canvas. `DeltaState` is deliberately *not* a multi-writer or merge mechanism: one authoritative writer, deltas fan out, consumers reduce — the same ownership story as `RemoteState`, with a wire format that scales to documents.