REALTIME ARCHITECTURE V3 — Multi-Pathway, Actor-Aware, Cost-Bounded

Status: deployed — the waves landed on main (cvl-node-tree-direct merged 2026-05-16). Supersedes the hot-path sections of the retired REALTIME-ARCHITECTURE-V2.md (pruned from the wiki; historical reference only). The 6-layer cockroach scaffolding (L0–L5) survives; this document narrows which layers fire on which path.

Author motivation: the v2 active path makes Cloudflare Durable Objects a hard dependency. At 1M users with ~10 actors per workspace this design does not fit free-tier compute and quietly bleeds cost. V3 reshapes the hot path so DO usage scales with multi-human concurrency, not user count.


Status — Implementation

What landed via cvl-node-tree-direct (merged into main 2026-05-16) and is deployed. The spine (D1 source-of-truth → broker as a thin byte-relay → SDK in @cv/workspace-client) is in place; the hot-path squeeze items shipped in waves.

Deployed

Area What Commits / sites
Rule 2 — Lazy WS open Surface registry on WorkspaceConnection; shouldOpenWs(presence, surface) gates the WS open; focus / visibility / awareness / surface-change re-evaluate (no setInterval). Solo human on /dashboard never opens WS. 715639d, 263ecec, 40a6f8e, 9abff4d. Predicate at ui/src/lib/realtime/connection-impl.ts:evaluateWsNeed. Surface mapping wired in [wsId]/+layout.svelte and view/[wsId]/+layout.svelte.
Presence — single source Humans counted from Yjs awareness via derivePresenceSummary; agents counted from GET /api/workspaces/:id/agent-presence. The parallel /presence endpoint added during Wave 1 was deleted as a parallel-system mistake. dba04ea (removed /presence), 9ef495c (presence from awareness + /agent-presence).
Immutable cache key + edge cache (Squeeze #10) GET /api/workspaces/:id/sub/:subDoc?since=N returns Cache-Control: public, immutable, max-age=86400 when N matches current clock; stale or unversioned 200s get no-store; 304s get must-revalidate. Routed through the shared withEdgeCache helper — not a parallel caches.default integration. SW does stale-while-revalidate on cold start. f4b36d8, 58eb568, cbe3dbc, 0cef4d8, 28f5f74 (helper extensions), f4c2a27 (refactor onto helper).
SSE Fanout Worker GET /api/workspaces/:id/changes/stream is a stateless text/event-stream that tails workspace_sub_doc_ops, workspace_process_requests, and workspace_agent_presence by ts_ms cursor. 25s hold + reconnect under the CF free-tier 30s ceiling. Three event kinds: sub-doc, process-request, agent-presence. Per V3 §169, no separate change_log table — events derived from existing timestamped tables. 3cb7abc (route + tailer), 1c0c248 (tests), e0c7775 (Dashboard + WorkspaceIntelligence consumers).
Optimistic local apply (Squeeze #1) CoalesceBuffer wired pre-Wave-1; same-tick cv/cl-profile ops on the same branch_path collapse into one node.batch; non-batchable ops drain one-per-request. c4d16ef.
Sub-doc partitioning (Squeeze #2) One workspace = settings, layout, order-state, jobs, cv-profile, cl-profile, notes as independent canonical workspace_sub_doc rows, each with its own Y.Doc + clock + edge-cache key. settings / layout / order-state were physically split out of a previously-combined settings Y.Doc so the frequent order_state.update writes (every drag-and-drop) no longer invalidate the edge cache for the rare settings.update and occasional layout.update reads. Pre-Wave-1 partitioning shipped earlier; settings/layout/order-state physical split + migration in ui/scripts/split-settings-sub-docs.ts. See CLAUDE.md §5–§6.
zstd-19 wire compression (Squeeze #3) Shipped earlier. Pre-Wave-1.
Skip no-op writes (Squeeze #4) Shipped earlier (apply pipeline short-circuits when projection compare is no-op). Pre-Wave-1; see 6541e01.
Cross-sub-doc atomicity (workspace.batch) planWorkspaceBatch groups sub-ops by sub-doc; server commits all CAS UPDATEs through env.DB.batch([...]); per-sub-doc broadcasts fan out only after the D1 batch commits. addLanguage seeds cv_profile + cl_profile atomically. 5de1932, bfa137a, d1945d6, d966248.
MCP idempotency via deterministic request_id POST /api/workspaces/:id/process-requests is INSERT-OR-IGNORE on request_id. SHA-256(workspaceId, normalizedUrl) → add_job; SHA-256(workspaceId, jobId, 'tailor'|'evaluate', branch_path) → tailor / evaluate_job. Concurrent agents collapse at the PRIMARY KEY. 7cd6d69 (endpoint + id helpers), 0aaf275 (add_job), ff5bd4c (tailor), 7fed37f (evaluate_job), 3c37c61 (contract pin).
op_id envelope + server dedup Every Op kind carries optional opId?: string. OP_CATALOG_VERSION is currently 10 (was 8 when this wave landed — see lib/mutations/src/opsCatalog.ts). Server dedup via INSERT OR IGNORE INTO workspace_sub_doc_ops ... (workspace_id, sub_doc, op_id); retried POSTs return the original commit clock. See CLAUDE.md §23 + lib/mutations/src/opsCatalog.ts.
y-indexeddb persistence + retry-safe writes SDK persists each loaded sub-doc to IDB; pending optimistic ops survive tab close and replay on reconnect with the same opId. 9403597, cae58a9.
process_request browser worker The Dashboard's leader tab claims queued process_request rows. SSE process-request events wake it sub-second when the tab is visible; the existing BROWSER_PROCESS_HEARTBEAT_MS = 5 min floor is the SSE-unavailable fallback. Dashboard.svelte:716-1045, e0c7775.

Still proposed

Squeeze item Status
#5 Long-poll while waiting proposed
#6 D1 batch writes partial — workspace.batch covers cross-sub-doc atomicity; broader op-buffer batching TBD
#7 Request-scoped retention / compaction proposed
#8 Cold-workspace archival to R2 proposed
#9 Workspace-scoped service binding for in-CF agents proposed

Principles

  1. D1 is the source of truth. Every meaningful write lands in D1 via a REST endpoint first. DO outage cannot lose data.
  2. Agents do not hold WebSockets. Agents are the majority of actors in a typical workspace. They speak REST/MCP and read D1 on tool-call boundaries. Optionally subscribe to a per-workspace push stream when they want low-latency awareness.
  3. DO fans out logical changes, not Yjs micro-ops. "Drag job to status X" is one broadcast, not N keystroke events. Yjs micro-op rate stays peer-local (P2P or one process).
  4. Yjs over WS only when ≥2 humans are co-editing a Yjs-relevant surface (text editors: notes, CVL). Otherwise REST + change-feed is enough.
  5. Multi-pathway resilience. Every mutation has at least two delivery channels: durable (D1 via REST) and live (DO / P2P / SSE / relay list). Either alone keeps the user safe.
  6. Vendor-portable wire format. Plain WS frames carrying Yjs sync. No Cloudflare-specific framing. Failover to a non-CF relay is a config change, not a rewrite.

Workload assumptions

Anchored in project_careervector_workspace_actor_model.md and project_careervector_scale_target.md:

  • ~10 actors per workspace typical: 1–3 humans + ~7–9 agents (Codex, Claude, Gemini, …).
  • ~100k workspaces at 1M users.
  • ~5 active min/day per human × 22 days/month.
  • Multi-human concurrent moments ≈ 5% of active time. The other 95%: one human + N agents.
  • Agents poll/subscribe at tool-call rhythm, not human-keystroke rhythm.

Architecture (hot path)

                ┌──────────────────────────────────────────┐
                │       D1 (canonical state)               │
                │   ← writes ← REST ← every actor          │
                │   change_log table tracks logical edits  │
                └─┬────────────────────────────┬───────────┘
                  │                            │
       change_log change-feed         read on demand
                  │                            │
        ┌─────────▼──────────┐      ┌──────────▼─────────┐
        │ SSE Fanout Worker  │      │ Agents (REST/MCP)  │
        │ stateless, per-ws  │      │ poll on tool calls │
        │ stream of {clock,  │      │ never open WS      │
        │ kind, path}        │      └────────────────────┘
        └─────────┬──────────┘
                  │
                  │ (agents that opt into push)
                  │
                  ▼
        ┌────────────────────────────────────────────────┐
        │                Humans                          │
        │  ┌─────────────────────────────────────┐       │
        │  │ Browser leader tab                  │       │
        │  │ Y.Doc + IndexedDB                   │       │
        │  │ BroadcastChannel ↔ follower tabs    │       │
        │  └──┬─────────────────┬────────────────┘       │
        │     │                 │                        │
        │     ▼ P2P (humans)    ▼ WS (signaling +        │
        │  ┌──────────┐         │      logical changes)  │
        │  │ y-webrtc │    ┌────▼──────────────────┐     │
        │  │ Yjs ops  │    │ RelayWalker picks     │     │
        │  │ peer↔peer│    │ the current entry     │     │
        │  └──────────┘    │ from PUBLIC_RELAY_URLS│     │
        │                  │ and advances on       │     │
        │                  │ failure               │     │
        │                  └───────┬───────────────┘     │
        │                          │                     │
        │                          ▼                     │
        │   ┌─────────────────────────────────────────┐  │
        │   │ cf  : CF DO (`realtime`, cf.relay.…)    │  │
        │   │       - signaling                       │  │
        │   │       - logical-change broadcast        │  │
        │   │       - hibernates idle                 │  │
        │   │ deno: Deno Deploy (`relay/deno/`,       │  │
        │   │       deno.relay.…) — same protocol     │  │
        │   │ …   : append more vendors with their    │  │
        │   │       own <vendor>.relay.… prefix       │  │
        │   └─────────────────────────────────────────┘  │
        └────────────────────────────────────────────────┘

The four load-shedding rules

Rule 1 — Agents do not open WebSockets

The dominant population of actors. Currently each agent connecting via the GUI client gets its own WS. New rule:

  • Agent runtimes hit /api/... REST + /mcp/... for tool calls.
  • Agents that want push notifications open one shared SSE stream per workspace at /api/workspaces/:id/changes/stream. The stream emits {clock, kind, path} for every logical change. Agents fetch full state via REST when they care.
  • One stream per agent process per workspace, not per tool-call. Agents reconnect on disconnection.

Effect: ~80% of actor-connections leave the WS layer.

Rule 2 — Browser opens WS only when needed

The leader tab evaluates on focus / visibility-change:

shouldOpenWs = (peerHumansPresent >= 2) || (currentSurface in {notes, cvl-editor})

peerHumansPresent is read from a lightweight /api/workspaces/:id/presence endpoint (D1 read, ~10ms). Polled lazily, not held open.

Otherwise the tab uses REST + the same SSE stream to receive logical-change notifications and re-fetch via REST. Solo-human-with-agents sessions never open a WS.

Effect: most active sessions cost zero DO compute.

Rule 3 — DO broadcasts logical changes, not Yjs micro-ops

Two distinct broadcast types, only one of which goes through DO:

Type Granularity Transport DO touches
Logical change One per user-meaningful mutation (drag, save, edit-field) DO broadcast to subscribers + D1 change_log Yes — but coalesced
Yjs op One per CRDT delta (per keystroke during text editing) y-webrtc P2P between humans No — DO never sees these

Coalescing happens client-side with a 250–500 ms ring buffer per logical surface. Aggregates rapid edits into a single "logical change" event.

Effect: ~10–50× fewer DO messages during active editing.

Rule 4 — REST is the durable backstop

Every mutation:

client.mutate(value):
  PUT /api/workspaces/:id/...    ← D1 write, returns clock N
  if (ws-connected):
    broadcast({clock: N, kind, path})  ← live notification, optional
  insert into change_log (clock, kind, path)  ← server-side, in REST handler

DO outage = no live sync; data still safe in D1. Reload recovers from D1. Agents catch up on next poll.


Resilience: multi-vendor relay (list-walker)

The realtime relay protocol is plain WS frames + the Yjs sync subset that v2 already speaks. No CF-specific extensions on the wire.

The browser ships with an ordered list of relay endpoints and walks it on failure. There is no special "primary vs alt" binary — every entry is a peer relay with the same protocol, and the list defines the failover order.

Configuration (single env var)

PUBLIC_RELAY_URLS is a build-time JSON array of {label, url} objects, ordered by priority. Each url is a WebSocket URL ending in /ws (the server mounts /ws/<workspaceId>); each label is a stable, unique key that doubles as the debug-pin key and the cockpit-probe rollup key.

Production value (ui/wrangler.toml [env.production.vars], mirrored in ui/vite.config.ts):

[
  { "label": "cf",   "url": "wss://cf.relay.careervector.corbet.ch/ws" },
  { "label": "deno", "url": "wss://deno.relay.careervector.corbet.ch/ws" }
]

Vendor-prefix convention

Relay hostnames live under <vendor>.relay.careervector.corbet.ch. The vendor prefix matches the relay's label so an operator who reads a telemetry line for label=cf can find the matching hostname without a translation table. Adding a third relay = pick a new vendor key (e.g. fly), provision fly.relay.careervector.corbet.ch, append to PUBLIC_RELAY_URLS.

Client walker (RelayWalker)

Each WorkspaceConnection owns a per-tab RelayWalker (ui/src/lib/realtime/relay-walker.ts). The walker:

  1. Starts at index 0.
  2. Advances when EITHER:
    • The WebSocket fails before onopen fires (close before-or-during handshake, pre-open error), OR
    • Two consecutive abnormal closes follow a successful open. Clean closes (code 1000) reset the post-open strike counter.
  3. After the last entry, wraps to 0 AND emits a relay_cycle_all_failed telemetry event so operators learn every relay is failing.
  4. Persists position across reconnect cycles within the tab (does not reset on clean close).
  5. Emits a relay_advance telemetry event per transition tagged with fromLabel, toLabel, and trigger.

Debug override (?relay=<label>)

Appending ?relay=cf (or any other known label) to the SK page URL pins the walker to that entry for the lifetime of the tab. While pinned:

  • The walker stays on the pinned entry through any number of failures (broken stays broken — the operator sees the real issue).
  • The cycle telemetry never fires.
  • Unknown labels: console.warn + fall back to default selection.

The pin is per-tab; opening a new tab without the param gets the normal walker behaviour. Useful for diagnosing whether a specific relay is the cause of a connectivity problem.


Math at 1M users (back of envelope)

  • 100k workspaces × 5% multi-human-concurrent active time × 110 active min/month × 2 logical-change broadcasts/min ≈ 1.1M DO broadcasts/month.
  • Free-tier DO ceiling: 1M req/month. Sits at ceiling without paid plan.
  • Workers Paid ($5 base + $0.15/M extra) at 10× this volume: ~$5–10/mo.
  • Cost grows linearly with active multi-human concurrency, not user count. A 10× user growth that keeps the same concurrency profile costs ~10× nothing.

D1 change_log writes: ~2 broadcasts/active-user-min × 100k users × 5 min/day ≈ 1M writes/day. Above free-tier D1 write budget (50k/day). Mitigations:

  • TTL the change_log to 7 days.
  • Co-locate the change_log row write inside the existing mutation transaction (no extra row in many cases — encode as a column on the main table).
  • Compress events: (workspace_id, clock, kind, path) keyed only by clock; many event kinds collapse to a counter.

Implementation plan

# Step Effort Touches
1 Agent SSE stream Worker + agents-no-WS contract 1 day new apps/sse worker; agent runtimes drop their WS client
2 Lazy WS open in browser leader tab 0.5 day ui/src/lib/realtime/connection-impl.ts
3 Logical-change coalescing client-side 0.5 day ui/src/lib/realtime/connection-impl.ts
4 REST writes alongside Yjs ops 0.5 day every mutation surface; endpoints already exist
5 Relay-list walker (RelayWalker) — open-failure / 2-abnormal-close advance, cycle telemetry, ?relay=<label> pin 0.5 day ui/src/lib/realtime/relay-walker.ts, ui/src/lib/realtime/connection-impl.ts
6 Second relay vendor (Deno Deploy relay-deno, plus its deno.relay.… symmetric hostname) 0.5 day relay/deno/ (Deno) + Cloudflare DNS for deno.relay.careervector.corbet.ch

Total ~3.5 days of focused work. No rewrite of the cockroach scaffolding. Both PHASE_4_THIN_RELAY=1 and the existing legacy mode survive untouched; V3 is additive on the client side.


Approved squeeze list (2026-04-28)

These compound. Each multiplies headroom on top of the baseline cost. Implementation state marked per row.

# State Move Mechanism Effect
1 ✓ deployed Optimistic local apply Client commits Yjs op locally before server confirms. UI is instant; server roundtrip becomes a background ack. CoalesceBuffer (commit c4d16ef) wires same-tick coalescing. Removes "did it save?" polls. ~30% fewer Worker requests.
2 ✓ deployed Sub-document partitioning One workspace = jobs / settings / layout / order-state / cv-profile / cl-profile / notes as independent canonical Y.Docs with own clocks + own edge-cache keys. settings / layout / order-state are now physically distinct rows (previously the three roots shared one settings row), so frequent order_state.update writes from drag-and-drop no longer invalidate the edge cache for the rare settings and occasional layout reads. Migration in ui/scripts/split-settings-sub-docs.ts. Edge cache hit rate climbs (each sub-doc's clock advances independently). ~3× fewer D1 reads under multi-actor load.
3 ✓ deployed zstd-19 on the wire (CLAUDE.md §23) Yjs binary is varint-redundant; compresses ~3×. D1 row size ÷3, edge cache density ×3, transit bytes ÷3.
4 ✓ deployed Skip no-op writes Yjs detects "this op produces identical state." Don't persist or broadcast. Cuts D1 writes for redo/undo loops, agent re-reads, idempotent retries. ~10–20% fewer writes.
5 ✗ proposed Long-poll while waiting One Worker invocation that holds for up to 30 s and returns on event. Replaces ~60 short polls. Worker request count ÷ 60 during operation-wait windows.
6 ◯ partial D1 batch writes Buffer ops for ~1 s, then db.batch([...stmts]) in one round-trip. workspace.batch (5de1932, d1945d6, d966248) covers cross-sub-doc atomicity through env.DB.batch([...]); broader op-buffer batching still TBD. Lower latency, fewer racy retries.
7 ✗ proposed Request-scoped retention / compaction Retention work should run from explicit user/agent activity or a bounded maintenance path, not an always-on deployed cron sweep. ops table stays bounded without idle Worker quota burn. Snapshot stays small. Cache stays small. Compounds with #2 and #3.
8 ✗ proposed Cold-workspace archival to R2 Workspaces inactive >30 days move snapshot to R2; D1 row becomes a stub. R2 read on first re-access, hydrate back to D1. D1 storage stays linear with active workspaces, not total.
9 ✗ proposed Workspace-scoped service binding for in-CF agents Agents running as CF Workers call /api/... via service binding → free, zero-egress. Removes external traffic for agents we control.
10 ✓ deployed Immutable cache key (wsId, sub-doc, clock) New mutations write new cache entries. Old entries self-expire. No invalidation logic. Routed through the shared withEdgeCache helper (f4c2a27); SW does stale-while-revalidate (58eb568). Edge cache becomes lock-free. Hit rate climbs.

Combined: cost baseline drops to ~$2–3/mo at 1M users, free-tier headroom ~10× more active concurrency.

#1, #2, #3, #4, #10 hard-bake into the SDK from day one. #5, #6, #7, #8, #9 land incrementally.

Reconciled cost model at 1M users / 100k DAU

Resource Estimated load CF tier Monthly cost
Workers requests 100k DAU × ~7 polls/session = 700k/day = 21M/month Paid base $5 + $0.30/M overage ~$8
D1 writes 100k DAU × ~30 logical mutations = 3M/day = 90M/month Free tier 50M/day comfortably $0
D1 reads (post-edge ETag) ~5% of polls hit content = 35k/day Free tier 25B/month $0
Edge cache fetches 700k/day, mostly local Free $0
DO requests multi-human signaling only, ~50k/month Free tier 1M/month, 20× headroom $0
TURN bandwidth ~5–10 GB/month Free tier covers 5GB; ~$0.20 overflow <$1
Total ~$8–10/month

$0.000008/user/month. With squeeze items #1–4 applied, drops to ~$2–3/month.

Cost shape scales linearly with active multi-human concurrency, not user count.

Three disciplines enforced by SDK + linter

  1. No background polling cadence by default. Polling is event-driven: focus, action, idle-tick at minutes (not seconds). The SDK's subscribe callback only triggers re-fetch on these events, not on a timer. Lint rule: no setInterval in any code path that touches the workspace API.
  2. All mutations through applyOp. No direct fetch('/api/workspaces/...'), no direct Y.Doc construction outside the SDK. Lint rule: ban these patterns in app code.
  3. Coalesce to logical units before writing. One D1 write per "user finished doing something." The Yjs ops still flow per-keystroke for P2P sync; D1 only sees coalesced units. Lint rule: every applyOp call site must pass through the SDK's coalescing buffer.

If any discipline breaks, the cost shape breaks. The linter is load-bearing.

Implementation plan (PR-shaped)

# State Chunk Notes
A ✓ deployed SDK foundation: @cv/workspace-client package, sub-document schema migration, zstd helpers, immutable-cache-key contract, types. Pilot one surface (kanban) end-to-end through it. Shipped; CoalesceBuffer (c4d16ef), IDB persistence + replay (9403597, cae58a9), op_id envelope (OP_CATALOG_VERSION = 8).
B ✓ deployed Read path: GET /workspaces/:id/:sub?since=N with ETag + edge cache, conditional 304 logic. Routed through shared withEdgeCache helper (f4b36d8, 28f5f74, f4c2a27); SW SWR (58eb568); tests in cbe3dbc. Not a parallel caches.default implementation.
C ◯ partial Write path: optimistic local apply, skip-no-op detection, batched D1 writes. Optimistic apply + skip-no-op shipped; workspace.batch lands cross-sub-doc atomicity (5de1932, d1945d6, d966248); broader op-buffer batching still TBD (Squeeze #6).
D ✗ proposed Cron compaction: scheduled Worker, ops→snapshot fold, prune compacted rows. Squeeze #7; see CLAUDE.md §22 quota policy before adding any cron.
E ✓ deployed Realtime/WS lazy open + P2P promotion: open WS only when peer_humans >= 2 OR surface ∈ {notes, cvl}, signaling-only DO usage. Lazy-open predicate at connection-impl.ts:evaluateWsNeed (715639d, 263ecec, 40a6f8e, 9abff4d). Presence from awareness + /agent-presence (9ef495c); the parallel /presence endpoint was deleted (dba04ea). Playwright smoke covers solo/dashboard, dashboard→cvl, cvl→dashboard.
F ✓ deployed Relay list + walker: ordered PUBLIC_RELAY_URLS JSON array; client RelayWalker advances on open-failure / 2 consecutive abnormal closes; wraps with cycle telemetry; ?relay=<label> pin for debugging. Cloudflare DO (cf.relay.careervector.corbet.ch) + Deno Deploy (deno.relay.careervector.corbet.ch). Replaces the binary primary/alt failover. See §"Resilience: multi-vendor relay".
G ✗ proposed Linter rules: ESLint plugin with no-direct-api-fetch, no-direct-ydoc, no-bypass-applyop, no-background-poll. CI gate. Still proposed; the disciplines hold by convention today.
H ✗ proposed Cold-workspace archival: Cron sweep of inactive workspaces to R2; lazy hydrate on access. Squeeze #8.
I ✓ deployed Migrate remaining surfaces through SDK. Dashboard, CVL editor, settings, jobs, profile, notes all route through @cv/workspace-client.
J (new) ✓ deployed SSE Fanout Worker: GET /api/workspaces/:id/changes/stream. Stateless tail over workspace_sub_doc_ops + workspace_process_requests + workspace_agent_presence (3cb7abc, 1c0c248, e0c7775). Per V3 §169, no separate change_log table — events derived from existing timestamped tables.
K (new) ✓ deployed MCP idempotency via deterministic request_id. add_job / tailor / evaluate_job enqueue workspace_process_requests rows with SHA-256 dedup keys; INSERT OR IGNORE collapses concurrent calls (7cd6d69, 0aaf275, ff5bd4c, 7fed37f, 3c37c61).

Open questions

  • Where exactly does the SSE Fanout Worker live? Cloudflare Workers cap long-lived streams at ~30s on free tier; on paid, streams are billable for the duration. Hetzner / Koyeb may be cheaper per stream-hour.
  • D1 change_log schema: separate table vs inline column? Decide based on benchmark of write amplification.
  • y-webrtc signaling channel: still goes through DO. Is a non-DO signaling path (HTTP-based ICE candidate exchange) feasible? Already partially in v2 (/api/p2p/signal) — promote to primary.
  • TURN bandwidth cost (Cloudflare Calls T2 tier) when humans are behind tight NATs and P2P falls back through TURN.
  • Multi-vendor failover semantics: what state does a fallback relay need to bootstrap from? Probably nothing — same Yjs sync from each peer's local Y.Doc.

Relationship to V2

V2's 6-layer cockroach scaffolding survives:

V2 layer V3 status
L0 cross-tab BroadcastChannel unchanged
L1 IndexedDB unchanged
L2 P2P (y-webrtc) unchanged, promoted to primary for human↔human Yjs
L3 DO relay unchanged code, demoted to logical-change broadcasts + signaling only; now one entry (cf) in the relay list rather than the only relay
L4 D1 cold-load unchanged, promoted to "consult on every focus event"
L5 R2 unchanged
L6 client-side LLM unchanged
NEW: SSE Fanout Worker between D1 and agents; non-DO push channel
NEW: Relay list + RelayWalker ordered PUBLIC_RELAY_URLS (today cf + deno), walker advances on failure; replaces the V2 binary primary/alt model

REALTIME-ARCHITECTURE-V2.md (retired — pruned from the wiki in 2cbd1969) was the design doc for the byte-relay mechanics. V3 is the workload-shaping layer on top of it.

Source: wiki/content/architecture/REALTIME-ARCHITECTURE-V3.md