oficina / docs Public

Live updates across tabs closed

On branch worktree-live-sync

Edits now reach every open editor of a document without a reload. Branch worktree-live-sync, six commits, gate green.

What it does

Open a document in two tabs, type in one, and the other updates. Same person, two tabs, two browsers, two devices — anywhere a document you own is open. This is the content half of §7, and the "later plan" the editing plan's D1 promised.

Scope was set by Paul before any design existed: server-mediated (not a same-browser BroadcastChannel), content only (no presence, no cursors, no sharing), over an SSE advisory rather than a WebSocket.

How

doc_updates.seq becomes a client-facing cursor. GET /d/{sqid}/updates takes ?since=N, both update routes answer with a Docs-Seq header, and a new GET /d/{sqid}/stream is a Server-Sent Events feed carrying a seq and no document bytes at all. The browser hears the seq and re-fetches through the binary endpoint it already used at mount — so there is one code path that turns stored updates into a Y.Doc rather than two that can drift, and SSE's text-only framing costs no base64.

Spec: docs/superpowers/specs/2026-09-01-live-sync-design.md. Plan: docs/superpowers/plans/2026-09-01-live-sync.md.

Why not the WebSocket §7.1 actually specifies

rastrillo v0.22.0 has no WebSocket helper, so it would mean a new dependency; the y-protocols sync handshake hand-rolled in Go, needing state-vector and diff entry points the Rust core does not export (an ABI bump, exactly what the editing plan's D2 avoided); y-websocket vendored into a hand-built bundle; Origin checked manually, since AGENTS.md's Sec-Fetch-Site reasoning does not cover a WebSocket handshake; and §7.3's hibernation problem made worse rather than deferred. Most of that expense buys awareness, which this slice excludes and which is unreachable anyway while documents have exactly one owner.

Review

Reviewed by Fable at the design stage, before the spec was written — verdict "approve with changes". It found two defects, both folded in before any code existed:

  1. Silent data loss (severe, and mine). My draft defined Docs-Seq as "the highest seq included, or the document's current max when the slice is empty". That second clause needs a separate MAX(seq) query, and reads go to the read pool while writes go to the single-connection write pool with nothing serializing them. A row committing between the two queries lets a client advance its cursor over content it was never sent — invisible until a manual reload. The header is now derived from the bytes in hand, which makes the interleaving unrepresentable. TestEmptySliceEchoesSinceRatherThanTheCurrentMax is written to fail against the rejected implementation.
  2. A tab that goes permanently stale. Closing an idle stream and reopening on visibility, focus or a local edit misses the commonest case there is: someone reading a long document on a tab that is already visible and already focused fires none of those three. That tab is the watching half of the whole feature. pointermove/scroll/keydown now count as activity.

It also caught that §7.1's fallback describes a per-connection sequence number with a high-water ack, which is not what this ships — see the ruling below. And it verified the cursor+1 rule against adversarial interleavings (two tabs racing, a beforeunload beacon racing a flush including the beacon-fires-but-page-survives case, a 5xx retry over a committed row, inverted response order, a resync mid-flight): none loses an update, duplicates one with effect, or wedges a cursor.

One ruling for Paul, and it is why this is not merged

§7.1 wants amending rather than annotating. What ships is not §7.1's fallback as written — that describes a per-connection counter, and this is a document-global log seq, durable in doc_updates, meaningful across connections and 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 the hibernation policy needs). So "the fallback shipped first" would be a false description of the code.

The spec's §9 records the options. This branch does not merge until that call is made.

Verification

  • .amadan/ci.d/10-gate exit 0.
  • Go: 6 new stream tests against a real httptest.NewServer (a recorder cannot express a stream — it hands over what a handler wrote once the handler returns), 6 hub tests including one under -race, 6 cursor/protocol tests.
  • Node: 12 tests for the client module, added to the gate. Two are regressions for the review findings above.
  • End to end in the real binary: real magic-link sign-in, an SSE connection held open over a real socket, a POST from a second connection returning Docs-Seq: 2, that seq arriving on the held stream, and ?since=1 returning the bytes.
  • In a real browser: headless Chromium, the shipped bundle, a fresh empty document; an edit posted from a second connection appeared in the tab within half a second with no reload, no console errors and no uncaught exceptions.

That last one matters because the Go suite structurally cannot see the bundle — as the formatting-and-export session found the hard way this week, an editor that silently discards every edit leaves the whole Go suite green.

Notes for whoever picks this up

  • The two-tab check I ran lives in a scratch directory, not in the repo, deliberately: formatting-and-export is landing scripts/browser-drive.sh on main, and this belongs there as a second page rather than as a competing harness. Task below.
  • "Live" means within about two seconds of the typist pausing: DEBOUNCE_MS stays at 2000ms, because shortening it spends headroom against maxRetainedUpdates (4096, no compaction) and the formatting toolbar makes that arithmetic worse. Snapshot cadence and compaction (§4.1) are the real prerequisite for making it snappier.
  • main currently carries fd1d21a Merge branch 'worktree-click-to-place-caret' into main — a merge commit, where the cadence in AGENTS.md says squash-merge only. Flagging rather than touching it.

Whole-branch review by Fable is in, and a fix round has landed at e412077. Verdict was approve with changes; the changes are done, the gate is green, and the branch is back to waiting on one ruling.

What the review found

It confirmed the implementation honours the spec — D1–D5 and §6 are all in the code as written, and both design-review defects are genuinely fixed rather than merely claimed (it checked, rather than trusting the spec's word for it). The hub cannot block a request goroutine, cannot leak, and cannot grow unbounded; the stream handler returns on every path it must; the committed bundle is byte-identical to a fresh build.

Then it found the two most embarrassing bugs on the branch, both mine, both in the tests written to guard the defects this whole slice exists to prevent:

1. The Docs-Seq regression test could not fail against the implementation it rejects. Every case asked with since == the current MAX — and max(since, nothing) and MAX(seq) agree on every one of those. The comment claiming "a MAX(seq) implementation returns 2 here and loses row 2" was simply wrong; the derived implementation returns 2 as well. The discriminating case is since > MAX, which is also the shape of the real TOCTOU: a client holding a seq the slice query cannot see. A guard-rail with nothing behind it is worse than no test, because it reads as coverage.

2. The client-side monotonic-cursor test was inert. Its "straggler" was delivered by settling a promise that had already resolved — a no-op. The assertions passed whether the module used max() or plain assignment, so the one client-side rule the spec says these tests must pin was untested.

Both are fixed, and both fixes were verified by breaking the code: I put the MAX(seq) implementation back and watched the new case fail with the intended message, then restored it; same for reverting max() to assignment on the client. A regression test nobody has watched fail is a guess.

3. A real defect, low severity. The save queue's post closure read the module-level currentLive rather than its own lifecycle's subscriber. attemptResync stops the old subscriber but cannot stop the old queue (save-queue.mjs has no stop, by design), so a POST issued before a resync and resolving after it fed noteOwnSeq to the fresh subscriber — whose cursor came from a GET predating that row. No interleaving loses visible content, but it breaks D3's stated invariant across lifecycles, silently. It now closes over a lifecycle-local reference.

Also fixed: a tab that loads already-hidden never fires visibilitychange, so it held a stream for the full idle window it never needed. And hub.go's comment justified not closing the channel by a race this code cannot have — broadcast holds the mutex from lookup through send. The decision stands; the reasoning was wrong, and AGENTS.md has a name for that.

One behaviour worth a decision, not a bug

A hands-free watcher goes stale at the first server TTL. Receiving events defers the idle close but cannot reopen after the server's 5-minute TTL cut, because the client treats every EventSource error — an expected TTL close included — as "drop it and wait for activity". A wall-display tab nobody touches shows five minutes of updates and then freezes until someone moves the pointer.

§6 arguably endorses this ("someone is there" implies input), which is why the review called it a product call rather than a defect, and why I have not changed it unasked at one in the morning. Worth knowing there is a clean fix if it matters: at error time EventSource.readyState distinguishes a close the browser intends to retry from a fatal one, so a TTL cut can be told apart from a dead server. That would mean relaxing §6.1's blanket "never rely on native reconnection", which was my rule and is deliberately stronger than it needs to be — es.close() does not trigger reconnection, so only server-side closes were ever the issue. Filed as task 18 rather than decided here.

Still blocking merge

The §9 ruling: amend the 2026-08-30 spec's §7.1 to describe the doc-seq protocol, or rule otherwise. The wider spec is untouched on this branch, so that is still open. Task 15.

One note for whoever reads the diff: main has not yet received the click-to-place-caret merge, so git diff main...worktree-live-sync includes that work too (show.html's flex/outline rules and placeCaretFromPage). The review read those parts as well and found them sound; they carry their own review round.

Merged and deployed. Paul ruled on both open questions ("all that sounds fine — merge and deploy"), so:

  • §7.1 is amended (0de38f8). It is now revision 3 and describes the doc-seq protocol as the transport, with the WebSocket as the upgrade that arrives with presence. The old per-connection-counter wording is recorded as the decision it was rather than quietly replaced — it could not have survived §7.3's idle timeout, since a per-connection counter cannot tell a reconnecting tab what it missed.
  • The connection policy stands as built, including the hands-free watcher that goes stale at the first server TTL. Written into the live-sync spec's §9 with the note on what relaxing §6.1 would take, if that ever changes. Task 18 closed as decided, not as fixed.
  • Merged as a6fa843, CI green at 0de38f8 before the merge, landing recorded in the ledger.
  • Shipped and promoted to edge, which is the channel docs.bds.oncarlos.com follows. Live now: the URL serves a6fa843, and GET /d/{sqid}/stream answers a 303 to /signin where it had no route at all an hour ago.

One operational note worth having: promotion alone did not put it live. Release adoption runs on an hourly timer, so edge pointed at a6fa843 for five minutes while the box kept serving fd1d21a-1. carlos restart cycled the instances and it picked the release up within seconds. Anyone deploying this app should either use carlos deploy (which ships, promotes and then waits on the URL) or promote-then-restart-then-verify — and should check x-carlos-version on the live host rather than trusting carlos channels, because those two disagreed for the whole of that window.

A correction to my own earlier flag

Further up this thread I flagged that main carried a merge commit where AGENTS.md's cadence says squash-merge only, and I implied someone had merged by hand. That was wrong, and I should not have implied it. amadan branch merge — the sanctioned gate command, the one the cadence tells you to use — produces a merge commit itself. My own merge just now came out as a6fa843 Merge branch 'worktree-live-sync' into main, two parents, from that command and nothing else. The fd1d21a I pointed at was the caret branch landing exactly the same way.

So nobody broke the rule. What actually exists is a discrepancy between AGENTS.md ("squash-merge to main", "no local fast-forward") and the tool that lands branches, which has no squash option at all. That is worth reconciling in one direction or the other, and it is Paul's call which: either the cadence text should describe what amadan branch merge does, or the merge wants a squash mode. Filed as task 19.

A knock-on: I recorded the landing with amadan ledger landed … -via squash, which is now a mislabel — it was a merge. Noting it here rather than trying to rewrite a ledger marker, since redaction is for content and this is just a wrong adverb on an accurate record.

The sibling session recorded my flag on discussion #11 in good faith; I have told them directly as well.

Merged as a6fa843 and deployed; docs.bds.oncarlos.com has been serving it since. Closing — the record stands above, and the follow-up work (compaction, then the WebSocket) has its own threads.