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
- D1 is the source of truth. Every meaningful write lands in D1 via a REST endpoint first. DO outage cannot lose data.
- 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.
- 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).
- Yjs over WS only when ≥2 humans are co-editing a Yjs-relevant surface (text editors: notes, CVL). Otherwise REST + change-feed is enough.
- 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.
- 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:
- Starts at index 0.
- Advances when EITHER:
- The WebSocket fails before
onopenfires (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.
- The WebSocket fails before
- After the last entry, wraps to 0 AND emits a
relay_cycle_all_failedtelemetry event so operators learn every relay is failing. - Persists position across reconnect cycles within the tab (does not reset on clean close).
- Emits a
relay_advancetelemetry event per transition tagged withfromLabel,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
- No background polling cadence by default. Polling is event-driven: focus, action, idle-tick at minutes (not seconds). The SDK's
subscribecallback only triggers re-fetch on these events, not on a timer. Lint rule: nosetIntervalin any code path that touches the workspace API. - All mutations through
applyOp. No directfetch('/api/workspaces/...'), no directY.Docconstruction outside the SDK. Lint rule: ban these patterns in app code. - 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
applyOpcall 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_logschema: 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.