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_requests rows with worker_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:

  1. Provide fetch, chain resolver, op submitter, and an abort signal.
  2. Implement a claim loop that polls (or subscribes to) the workspace_process_requests queue with the right worker_class.
  3. 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.

Source: wiki/content/canon/compute-backend.md