Compute Backend — one engine, many hosts, no drift
CareerVector splits "what to execute" from "where to execute it." The engine
is one TypeScript module tree under lib/domain/src/pipeline/stages/; the
backends are the concrete hosts that import and run it (browser tabs, the CF
Worker fallback, and future MCP/Claude-Code agents).
Shape
All actors (human / MCP / pipeline / agent)
│
▼
┌────────────────────────────────────────┐
│ Submission surface │
│ - workspace_process_requests (async) │
│ - POST /api/workspaces/:id/ops (sync) │
└─────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ One engine (shared TS) │
│ lib/domain/src/pipeline/stages/* │
│ + Typst WASM at the render leaf │
└─┬───────────┬───────────┬──────────────┘
│ │ │
Browser CF Worker Future agent
(default) (60s floor) (Claude Code,
headless MCP)
Every backend imports the same stage executors and the same op apply path. The compiler bundles the code twice (Vite for the browser, wrangler/esbuild for the CF Worker), but the source is one tree.
Self-selection by economics
Backends do not negotiate — they claim work from a queue, and the queue's policy sorts the economics out.
- Browser is preferred. Browser CPU is free, BYOK keys live in
localStorage, and the workspace state is already loaded. Tabs claim
workspace_process_requestsrows withworker_class='browser'via an SSE wake. - CF Worker fallback claims
worker_class='server'only after a staleness floor (SERVER_FALLBACK_MIN_QUEUED_AGE_MS, currently 60s). The server is the last-ditch backend; it never preempts a healthy browser. - External agent (Claude Code session, headless MCP runner) can claim
worker_class='agent'for its own work. Same protocol; no schema change needed. This is a primitive, not yet a shipping product.
The 60-second floor is the cost story in one number: a cheap backend gets first refusal; only after they have all declined does the metered backend take the job.
Why drift is impossible
Before the pipeline-stage extraction (commits e568bb5 through b7ae38f),
pipeline.svelte.ts and the /process-requests/fallback route handler each
carried their own copy of stage logic. Four real drift bugs lived in that
gap. Folding both onto a shared engine closed the gap. A CI bundle-
equivalence check enforces it going forward.
What is and isn't a backend
The compute backend hosts work execution. It does not host CRDT state
replication. The Yjs sync channel (WS frame relay over relay/cf and
relay/deno) is orthogonal to backends; it ferries bytes regardless of
which backend will eventually claim the work those bytes describe.
Read endpoints (/sub/:subDoc, /cell-origins, /intelligence) are not
compute backends either. They are pure D1 reads through SvelteKit routes;
there is no work to claim, so there is nothing for the engine to do.
Engine shape
Every stage executor has the same signature:
(input, deps) → { patch, origins, telemetry, result }
deps is an injected bag — fetch, chain resolver, op submitter, abort
signal — so the engine itself has no hard binding to a browser API or a CF
Worker binding. The browser supplies its deps from Svelte state; the
Worker supplies its deps from the request scope. The engine never knows
which one it is running in.
How to add a backend
Implementing a new backend is a matter of supplying the engine's deps
imports and registering with the process-request queue:
- Provide
fetch,chain resolver,op submitter, and an abort signal. - Implement a claim loop that polls (or subscribes to) the
workspace_process_requestsqueue with the rightworker_class. - Emit terminal status updates via
PATCH /api/.../process-requests/:id.
No other surface needs to change. The engine, op catalog, cell-origin projection, and singleflight locks are all backend-agnostic.
Why "client compute default" is a principle, not a default
Cloudflare Workers compute is the metered resource. Browser tabs and headless agents are not. Reducing the cost of the product to "rent a CF cron tick to keep the queue moving" is only possible because the engine and submission surface are uniform. If a backend ever needs to be the primary runner, nothing in the engine has to change — only the staleness floor and the priority knob do.