oficina / docs Public
Live Updates Across Tabs — Design
Status: designed, reviewed by Fable (2026-09-01), one ruling open for Paul (§9). Spec it extends: docs/superpowers/specs/2026-08-30-docs-design.md — §4 (data model), §4.1 (the log is bounded), §4.3 (rejected eventlog), §7.1 (merge and transport), §7.3 (presence and hibernation). Plan it produces: docs/superpowers/plans/2026-09-01-live-sync.md.
1. What this is
Open a document in two tabs, type in one, and the other updates without a reload. Same person, two tabs, two browsers, or two devices — anywhere a document you own is open.
This is the content half of §7's collaboration story, and only that half. It is the "later plan" the editing plan's D1 promised: "Live collaboration, presence, and the socket's idle timeout arrive together in a later plan, and the update-log format this plan establishes is what that plan will stream." Presence and the socket do not arrive here — see §8 for why they are separable, and §10 for what is deliberately left out.
2. Where the code is today
Editing is plain HTTP. The browser fetches GET /d/{sqid}/updates (internal/docs/handlers.go:311), which returns every row of the document's update log concatenated in seq order, and replays it into a Y.Doc. Edits produce Yjs updates which are debounced (2000ms as this design shipped; 250ms since — §7.5), merged, and POSTed back (internal/docs/handlers.go:359), validated through the Rust core and appended to doc_updates. Both routes are owner-scoped through one helper (getDocumentForUpdates, handlers.go:277) and sit inside the RequireSession group under csrf.Protect (app.go:435–455).
Nothing pushes to an open editor. A second tab sees changes only on reload. That is the whole gap this design closes.
Two existing mechanisms this design leans on rather than replaces:
doc_updates.seqis allocated MAX+INSERT inside a transaction (store.go:539). The long comment above it explains why that is safe rather than racy: rastrillo/db caps the write pool at exactly one connection, so a second concurrentApplyUpdatecannot begin its transaction until this one commits.- The client's resync path (
attemptResyncinfixtures/editor-entry.js, driven byresync-controller.mjs) already rebuilds theY.Doc, the save queue and theEditorViewwholesale from the server's log. Live updates hang off that same lifecycle rather than inventing a second one.
3. Transport: SSE advisory, not a WebSocket
§7.1 specifies a WebSocket with an SSE fallback. This ships an SSE-shaped transport first, deliberately.
What a WebSocket would cost here. rastrillo v0.22.0 has no WebSocket helper — a new dependency in a deliberately lean go.mod. The y-protocols sync handshake would be hand-rolled in Go, needing state-vector and diff entry points the Rust core does not export; that is an ABI bump, which is exactly the work the editing plan's D2 avoided by having the browser replay raw update bytes. y-websocket would need vendoring into a bundle this app builds by hand. Origin would need checking manually, since AGENTS.md's Sec-Fetch-Site reasoning covers ordinary requests and not a WebSocket handshake. And §7.3's hibernation problem would get worse rather than deferred. Most of that expense buys awareness, which §8 removes from this slice.
What this costs instead. One extra round trip per change, and a transport that is not yet what §7.1 describes.
Rejected alternatives. Polling ?since= on a timer: simplest, but "live" becomes seconds late and a parked tab polls forever, which pins a hibernating instance harder than a stream that can close. A same-browser BroadcastChannel: nearly free, but only syncs one browser's own tabs, which is not what was asked.
3.1 The stream carries no document bytes
jobs/events.go in rastrillo — the only text/event-stream producer in the framework — carries no HTML, on the reasoning that keeping one rendering path is worth more than saving a round trip. The same reasoning applies to bytes: the stream carries a seq number, and the client fetches the actual updates through the same binary endpoint it already uses at mount, through the same applyServerLog.
This also sidesteps SSE being a text protocol. Yjs updates are opaque binary; putting them on the stream would mean base64 and roughly 33% overhead on every keystroke batch, plus a second code path for document bytes that would need its own tests and could drift from the first.
4. Protocol
D1 — seq becomes a wire value
doc_updates.seq already exists and is already allocated safely. This promotes it from an internal ordering column to part of the client-facing contract.
Store.ApplyUpdatereturns(int64, error)— the assigned seq — instead oferror. The seq is meaningful only whenerr == nil.GET /d/{sqid}/updatesaccepts an optional?since=Nand returns only rows withseq > N.maxLogBytesstill applies to the slice, still checked before the first byte is written.- Both
GETandPOST /d/{sqid}/updatescarry aDocs-Seqresponse header. - A malformed
since(non-numeric, negative) is a 400.
D2 — Docs-Seq is derived, never queried
Docs-Seq = max(since, highest seq in the bytes actually returned).
An earlier draft said "the highest seq included, or the document's current max when the slice is empty". Fable's review found that this loses an update, silently and permanently. The second clause needs a separate MAX(seq) query, and reads go to the read replicas while writes go to the single-connection write pool — nothing serializes the two:
> Tab A is at cursor 45 and fetches ?since=45. The slice query returns empty. Tab B's > POST commits row 46 and broadcasts. A's MAX(seq) query then runs and returns 46. > A receives an empty body and Docs-Seq: 46, and advances its cursor to 46 having > never applied row 46. The broadcast that would have rescued it arrived while A's > fetch was in flight and was coalesced into a follow-up ?since=46 — which is also > empty. Row 46 stays invisible in tab A until a manual reload.
Deriving the header from the bytes in hand makes that interleaving unrepresentable: an empty slice echoes since back, so a cursor never moves over a row the client did not receive. The no-since initial GET on an empty document derives 0, which seeds the cursor correctly.
The client half of the same rule: a cursor only ever moves forward. cursor = max(cursor, header), never assignment, so a response that arrives out of order cannot drag a cursor backwards.
D3 — the cursor+1 rule for a tab's own POST
A tab that POSTs and gets Docs-Seq: 43 back may advance its cursor to 43 only if its cursor was 42 — only if seq == cursor + 1.
Otherwise another tab wrote rows in between that this tab has not applied, and adopting 43 would skip them permanently. When the rule does not hold the cursor stays put, the tab is "behind", and an ordinary ?since= fetch — triggered by the stream, or by the connect-time event on the next reopen — heals it.
The rule earns its place by removing the echo round trip: in the common case (one tab typing) every POST advances the cursor without a fetch, and the broadcast for that seq is a no-op when it arrives.
Fable constructed the adversarial interleavings — two tabs racing, a beforeunload beacon racing a flush including the case where the beacon fires but the page survives, a 5xx retry over an already-committed row, two concurrent flushes from one tab with responses arriving inverted, and a resync mid-flight — and none of them loses an update, duplicates one with effect, or wedges a cursor permanently. A cursor that fails to advance is always healed; that is the property to preserve if this rule is ever changed.
D4 — broadcast on success only
The handler broadcasts after ApplyUpdate returns a nil error, never before and never on an error.
This matters because ApplyUpdate commits the doc_updates row in its transaction and then bumps documents.updated_at outside it. If that bump fails, the method returns an error over a row that is already durable. The handler 500s, sends no Docs-Seq, and broadcasts nothing; the client's save queue treats a 5xx as retryable and re-POSTs the same bytes, which lands a duplicate row. That is accepted: Yjs integration is idempotent so the document converges, the cursor+1 rule keeps the client honest (its cursor never moved), and the cost is one wasted row against maxRetainedUpdates. The same is true of a connection dropped after commit.
The alternative — broadcasting from inside Store.ApplyUpdate — is rejected in D5.
D5 — the broadcast seam is the handler, not Store
Store stays a persistence type that knows nothing about transports, and doc_updates stays "a plain append-only table" as §4.3 insisted when it rejected eventlog for carrying JSON payloads and a redundant Lamport order.
The accepted consequence: a writer that does not go through updatesPost does not notify. Today the only other writer is sample seeding (seedIfEnabled → Seed → ApplyUpdate), which runs when a person's row is first created — before any editor of theirs can be open — so the consequence has no reachable case. A future importer would need to notify explicitly, and this paragraph is the note that says so.
5. Server design
5.1 The hub — internal/docs/stream.go (new)
A map[int64]map[chan int64]struct{} behind a mutex: subscribe by document id and get a one-slot channel, unsubscribe on stream close. Sends are non-blocking — a full slot means a notification is already pending, and since every client fetch is since=cursor, coalescing a burst into one wake-up loses nothing. The seq on the channel is advisory: it tells a client that there is something new, and the fetch determines what.
One instance per team, one process — the docs spec's opening paragraph ("One instance per team") and its §3, System shape ("One SQLite file per instance") — so there is nothing to coordinate across machines and no shared-broker-shaped hole here.
5.2 The stream — GET /d/{sqid}/stream
Owner-scoped through the existing getDocumentForUpdates helper, inheriting its 404-not-403 behaviour unchanged (a stranger must not learn the document exists). Registered in the same RequireSession group as the update routes; a GET, so csrf.Protect passes it through untouched.
The body follows jobs/events.go: an http.Flusher check, http.NewResponseController, text/event-stream with Cache-Control: no-store, a 200 and a flush before the first event, a 30-second write deadline re-armed before every write, a : ping comment heartbeat every 15 seconds, a 5-minute TTL, and a select that includes r.Context().Done() so a graceful drain is never held open.
Each piece of that recipe is carried for its own reason, not by imitation:
- The write deadline is what lets a stream coexist with
serve's zeroWriteTimeoutdefault (serve.go's own Timeouts recipe).cmd/docs/main.gogoes throughrastrillo.Resolvewith the defaults, so that zero applies here. - The heartbeat is what makes the write deadline ever fire on a quiet stream. It is what bounds discovery of a dead client to about 45 seconds instead of never.
- The TTL is the hard bound on any one connection, and half of the hibernation story (§6).
- The 1-second database tick is dropped.
jobs/events.gopolls because, in its own words, it had no broadcast primitive to wait on. This has one, so the handler blocks on the hub channel and never polls. One thing the tick also did is not replaced: it re-checked the entity's existence every second and could emitgone. There is no delete route yet, and the TTL bounds the exposure to five minutes; when deletion lands, this needs revisiting.
Events are event: update with data: <seq>. One is sent immediately on connect, carrying the highest seq the server currently has. That event is load-bearing rather than an optimisation: it is what heals a tab whose cursor legitimately failed to advance (D3), and what covers the gap between the page rendering and the stream opening. It gets its own test.
5.3 Deliberately omitted: a subscriber cap
Every route here is behind RequireSession, a document has exactly one owner today, and a subscriber costs a goroutine and a one-slot channel. Revisit when sharing lands and a document can have many viewers.
6. Hibernation (§7.3)
§7.3's requirement: "The editor socket has an idle timeout: no edits and no presence activity for a set period closes it, and any activity reconnects. A parked tab stops holding the instance open."
Server. The 5-minute TTL is the hard bound. No timer exists outside a live connection, so nothing here is a time.Ticker that dies at hibernation — the trap §4.1 and §7.3 both name.
Client. Three rules, and the first is the one an implementer will get wrong:
- The module owns every open and every close.
EventSourcereconnects natively on any server close, which would defeat every policy decision below — including the idle-close the module just made. So native reconnection is never relied on: every close is an explicites.close(), and every open is a deliberate decision. This is the one piece ofjobs/events.go's recipe whose reason does not survive the move: that handler relies on native auto-reconnect precisely because it has no close policy of its own. Miss this and a parked tab's browser reopens the stream forever, which is §7.3's exact failure. - Close when hidden, or when idle. A hidden tab closes immediately; a visible one closes after an idle period with no local edits and nothing received.
- "Any activity" means any activity. Visibility, focus and local edits are not enough: a tab that is already visible and already focused while its person reads a long document fires none of them, so the idle timer would close the stream and nothing would ever reopen it — and the watching half of "type in one, watch the other" would go permanently stale. Passive
pointermove,scrollandkeydownlisteners count as activity, both deferring the idle close and reopening a closed stream.
A visible, active tab reconnecting every five minutes does hold its instance awake. That is intended: someone is there.
7. Client design
7.1 A new module, fixtures/live-updates.mjs
Follows the convention save-queue.mjs and resync-controller.mjs set — dependencies injected, so it runs under plain Node with no DOM, no EventSource and no real Y.Doc, and its own .test.mjs goes in the gate. It owns:
- the cursor, and D2's monotonic rule;
- the
EventSourcelifecycle and every policy in §6; - fetch serialization: one in-flight
?since=request at a time, with a pending flag, so a burst of notifications cannot interleave two applies; - delivering fetched bytes to its caller.
Two rules its tests pin, because both are currently true by accident rather than by construction: a stopped instance drops a late-resolving fetch rather than delivering it, and a stream event is a fetch trigger, never a cursor assignment.
7.2 Wiring, confined to attemptResync
attemptResync already rebuilds the Y.Doc, save queue and EditorView wholesale, and is used both for the first mount and for every recovery. Live updates join that lifecycle: a currentLive alongside currentQueue/currentView, stopped and replaced exactly as those are. The initial GET reads Docs-Seq to seed the cursor. Fetched bytes go through the existing applyServerLog.
Nothing in save-queue.mjs changes. The post closure that editor-entry.js already supplies becomes an async closure that reads Docs-Seq on a 2xx and returns the response unchanged; the queue consumes only r.ok and r.status. The beforeunload beacon bypasses post and reads no response at all, which is harmless: it moves no cursor.
7.3 Echo, divergence, and undo
Echo needs no new mechanism. applyServerLog already tags replayed bytes with Y.readUpdate(decoder, ydoc, 'server'), and the ydoc.on('update') handler already returns early on origin === 'server'. ySyncPlugin does not re-emit remote-origin changes as new Yjs updates either, so there is no ProseMirror-level echo.
Divergence. While resyncController.diverged is true the local document is known poisoned and a rebuild is coming, so the subscriber stops applying. attemptResync then replaces it wholesale. No new state machine.
Undo. yUndoPlugin already does the right thing: its Y.UndoManager tracks only ySyncPlugin's own origin, so 'server'-origin updates are never captured and Mod-z after a remote edit undoes your own last edit rather than someone else's. Do not add 'server' to trackedOrigins. This sentence exists because that change looks like a fix and is a bug.
Two known cosmetic behaviours, neither data loss: a remote transaction landing mid-IME-composition can cancel the composition, and undo history resets on a resync because the whole document is rebuilt.
7.4 A dead stream is a freshness failure, not a save failure
[docs-save-status] means "your edit did not save". A stream failure must never light it: POST is unaffected and edits are still saving. Repeated EventSource failures stop the stream and leave the tab to heal on the next activity-triggered reopen. Logged to the console; nothing user-visible in this slice, and therefore no new catalog keys.
7.5 The debounce — 2000ms at the time of writing, 250ms since
DEBOUNCE_MS sets the floor on how live this can feel: the other tab cannot see an edit before the originating tab sends it.
As this design shipped, it was 2000ms, and that was not a decision about feel. Every accepted POST is one doc_updates row, and internal/core/core.go's maxRetainedUpdates (4096, no compaction at the time) capped a document at that many before it refused edits permanently. So the constant was headroom against a cliff — 4096 × 2s is about 2h16m of continuous typing, 4096 × 0.8s about 55 minutes — and "live" therefore meant "within about two seconds of the typist pausing", stated plainly rather than papered over.
Superseded 2026-09-01 by the snapshots work (2026-09-01-snapshots-design.md, landed as 6bb0a03). Replay now starts from a snapshot plus a bounded tail, the cliff is gone, and the constant became free to be about perception. It is 250ms — under the roughly 300ms at which a delay stops reading as a response and starts reading as a wait. "Live" now means within about a quarter-second of the typist pausing, plus a round trip.
What that costs is stated where it belongs, in DEBOUNCE_MS's own comment: eight times the row rate, bounded for replay by snapshots and not bounded for storage, because §4.1's compaction horizon is still deferred. The mechanism that would return the rows without returning the latency is §4.1's coalescing at the storage boundary, which belongs with the WebSocket transport.
8. Why presence is separable
§7.3 ties presence to the socket, and this design has no socket. That is not a deferral of something half-built: presence needs ephemeral, high-frequency, upstream-and-downstream messages (awareness), which is exactly what a WebSocket is for and exactly what an SSE-plus-POST pair is bad at. Building presence on this transport would mean building the wrong thing twice.
It is also unreachable today. Documents are owner-scoped — internal/docs/models.go has Document, Person and DocUpdate and no grants table — so the only "other viewer" is the same person's other tab, and a presence stack that shows you yourself is worse than nothing. Presence arrives with sharing, on a socket, as §7 always intended.
9. Ruling, made 2026-09-01
§7.1 should be amended rather than annotated, and the wording matters.
§7.1's fallback describes "the client POSTs batched updates with a monotonic sequence number per connection, and the server acknowledges the high-water mark". What this design ships is not that: Docs-Seq is the document's global log seq, durable in doc_updates, meaningful across connections and across tabs. It is a different protocol and a better one — a per-connection counter cannot heal a tab that reconnects, which is most of what §6 needs.
So "the fallback shipped first" would be a false description of the code.
Paul ruled on 2026-09-01: amend it. The docs design spec's §7.1 is now revision 3 and describes the doc-seq protocol as the transport, with the WebSocket as the upgrade that arrives with presence. That edit is part of this branch, and it records the correction rather than quietly overwriting the old wording — a per-connection counter was a decision, and one that could not have survived §7.3's idle timeout.
The same ruling accepted §6's connection policy as it stands, including its one known consequence: a hands-free watcher goes stale at the first server TTL. Receiving events defers the idle close but cannot reopen after the server cuts the stream at five minutes, because the client treats every EventSource error — an expected TTL close included — as "drop it and wait for activity". A wall display nobody touches shows five minutes of updates and then freezes. That is the honest reading of §6.3's "any activity reconnects": some activity is still required. If it ever needs to change, EventSource.readyState at error time distinguishes a close the browser means to retry from a fatal one, which would mean relaxing §6.1's blanket rule — deliberately stronger than it needs to be, since es.close() never triggers a reconnection and only server-side closes were ever at issue.
10. Non-goals
Presence, remote cursors, sharing and grants, WebSocket transport, offline (y-indexeddb), snapshots and compaction. Each belongs to a spec section that already describes it; none is blocked by anything here.
11. Testing
- Go,
internal/docs/stream_test.go(new).httptest.NewRecordercannot express a long-lived stream — it cannot observe bytes before the handler returns — so these usehttptest.NewServerand read the body incrementally, with short injected durations (mirroringjobs/events.go's injectabletick/ttl/heartbeat). Cases: a POST from another request reaches a subscriber asevent: updatewith the right seq; the connect-time event carries the current max; a stranger's document 404s; the TTL closes cleanly; a heartbeat appears on a quiet stream; a cancelled request context ends the handler. - Go,
internal/docs/updates_test.go(extend).?since=slicing;Docs-Seqon both GET and POST; D2's derivation — an empty slice echoessinceand never reports a higher number; a malformedsinceis a 400;maxLogBytesstill enforced on a slice. - Node,
fixtures/live-updates.test.mjs(new). D3 including the case where the cursor must not advance; D2's monotonic cursor under an out-of-order response; fetch serialization under a burst; notification coalescing; the §6 close/reopen policy including the visible-and-idle case; a stopped instance dropping a late fetch. - Acceptance.
./scripts/run-dev.sh, one document open in two tabs, type in one, watch the other. That is the ask, and it belongs in the plan as a step. .amadan/ci.d/10-gategreen, with the rebuilt editor bundle committed.
12. Review record
Reviewed by Fable on 2026-09-01, per AGENTS.md's highest-available-capability rule, at the design stage rather than after implementation. Verdict: approve with changes. Two defects found and folded in above — the Docs-Seq TOCTOU (D2) and the visible-idle-tab staleness (§6.3) — along with the EventSource auto-reconnect rule (§6.1), the broadcast-on-success contract (D4), the trackedOrigins warning (§7.3), and the §7.1 wording correction (§9). The review verified all three of the design's claims about existing code, and confirmed rastrillo v0.22.0 offers no WebSocket, pubsub or streaming helper beyond jobs/events.go.