CareerVector — Cloud Infra & Vendor Reference
Canonical reference for which cloud vendor runs what, why, and the hard rules that keep the architecture sustainable.
Status: live spec. If reality and this doc disagree, fix one or the other immediately. Last updated: 2026-04-28.
The headline rule
FREE TIER ONLY. ALWAYS. Across every vendor, on every service.
This is not a budget constraint — it is an architectural discipline:
- Cloud bills kill startups silently. Many shipped products bleed cash on usage that was "fine in beta." CareerVector must not be one of them.
- Architecture only degrades in production. The design that "barely fits" the free tier becomes the design that overruns the paid tier once usage scales.
- Free-tier discipline is a forcing function for resource-respecting design. Coalesce, hibernate, cache, throttle, P2P. Doing the right things in the small makes the system survive at large scale.
- A bill with a recurring line item we don't need is one we will forget to cancel.
If any architecture proposal requires a paid tier to function at all, reject it. Paid tier is for headroom and emergencies, never the floor.
This rule was hard-learned on 2026-04-28 when an agent provisioned Koyeb nano (paid) without authorization to work around an exhausted free slot. The service was deleted within ~10 minutes of discovery.
Vendor inventory
Cloudflare — primary platform (free tier)
Account ownership and IDs are operator-managed. Authenticate wrangler with an
operator-owned Cloudflare account, and provide any required provider IDs
through local environment variables or CI secrets. Do not commit provider IDs,
emails, API tokens, or private local paths.
What CF runs:
| Service | What | Free-tier limit | Used by |
|---|---|---|---|
| Workers | SvelteKit app, REST API, MCP worker, DO-hosting relay | 100k req/day | ui, relay-cf, mcp |
| Durable Objects | WebSocket fan-out + signaling (V3-demoted) | ~5M req/month + per-request duration cap | relay-cf's WorkspaceDoc class |
| D1 | Canonical workspace state — snapshots + ops log + workspace metadata | 5M reads/day, 100k writes/day, 5GB storage | careervector (production); staging is retired — see wiki/content/runbooks/STAGING.md |
| R2 | Binary storage for snapshots, files, archived workspaces | 10GB storage, 1M Class A ops/month, 10M Class B ops/month | not yet active; planned for cold-workspace archival (chunk H) |
| Cache API | Edge cache for workspace snapshots, ETag short-circuit | No documented quota | will be used by chunk B (read path) |
| Cron Triggers | Not currently deployed | Free with Workers | Avoid periodic Worker sweeps; current recovery is request-scoped to preserve free-tier headroom. |
| Workers KV | Not in use | 100k reads/day, 1k writes/day, 1GB | — |
| Cloudflare Calls TURN | WebRTC TURN fallback for tight NATs | ~1k GB/month bandwidth (verify) | not yet active; planned for chunk E |
The CF "spine" hosts data and reads. The split-vendor pattern (below) layers Deno on top for the WebSocket-shaped workload that CF can only serve via the limited DO free tier.
CF DO has a hard structural limitation: WebSockets on Workers can only be accepted via Durable Objects. There is no non-DO WebSocket path on Cloudflare. This is the one capability where CF's free tier is structurally weaker than alternatives.
Deno Deploy — secondary platform (free tier)
The Deno Deploy project is operator-managed. Provide DENO_DEPLOY_TOKEN through
the local shell environment or CI secrets. Do not commit Deno tokens or private
local paths.
What Deno runs:
| Service | What | Free-tier limit | Used by |
|---|---|---|---|
| Deploy app | Stateless WebSocket fan-out relay | 1M req/month, 15h CPU/month, 20GB egress, 350 GB-h memory | the deno entry in PUBLIC_RELAY_URLS (wss://deno.relay.careervector.corbet.ch/ws), backed by Deno project careervector-relay-deno (upstream hostname is careervector-relay-deno.corbet.deno.net). Source at relay/deno/. |
Deno sits second in the relay list because its free tier is small. At 100k DAU, Deno's free tier (15 CPU-hours/month, 20GB bandwidth) cannot absorb the full WS traffic on its own. CF DO has the headroom; Deno does not. The client RelayWalker (see REALTIME-ARCHITECTURE-V3.md §"Resilience: multi-vendor relay") starts at the cf entry and advances to deno on open-failure or two consecutive abnormal closes after a successful open. Wraps to cf after the last entry, emitting relay_cycle_all_failed telemetry. There is no separate health-driven re-probe — the walker stays where it lands until it next fails, and a fresh tab restarts at index 0.
Practically: Deno carries traffic only when the cf entry fails for that tab. CF DO outages and CF-side quota burns are rare → Deno's free tier is wildly overprovisioned for this duty.
Hard rule for Deno: NEVER provision a paid Deno tier. If load on Deno trends toward the free cap, the right response is to fix the upstream issue (CF DO outage, runaway client retry loop) rather than upgrade.
WebSocket billing model on Deno is undocumented but the protocol-layer reading strongly favors per-connection (1 request per WS upgrade, frames inside are not requests). The empirical answer doesn't change the deployment plan, so we don't need to confirm it.
Note: cockpit-side health probes hit careervector-relay-deno.corbet.deno.net directly because CF Workers' fetch loops on in-zone CNAMEs that route externally — the browser-visible URL is the symmetric deno.relay.careervector.corbet.ch, the probe URL is the upstream *.corbet.deno.net.
Koyeb — tertiary platform (FREE TIER ONLY)
The Koyeb organization is operator-managed. Provide Koyeb credentials through local environment variables or CI secrets. Do not commit organization IDs, API tokens, or private local paths.
What Koyeb runs:
| Service | What | Free-tier limit | Used by |
|---|---|---|---|
| typst | Typst PDF compilation service | 1 free instance per account, scale-to-zero, deep-sleep when idle | CV/CL PDF render path |
Koyeb has exactly one free instance per account, and it is permanently allocated to typst.
Hard rules for Koyeb:
- Never deploy a second Koyeb service. The free slot is taken. Any new service would land on paid tier. This has happened once (2026-04-28, the alt-relay incident). It must not happen again.
- Never provision a paid Koyeb instance type (
nano,micro,small, anything that isn'tfree). If a workload requires more than the free slot, deploy it on a different vendor instead. - Treat Koyeb as full. When asked to deploy something, the answer is "Koyeb is full — choose another vendor." There is no "we'll just use the smallest paid tier" workaround.
Koyeb deployment lessons:
- The Koyeb REST API does accept
dockerfilemode (the typst service was migrated frombuildpacktodockerfilemode via REST on 2026-05-18 when the bun migration broke buildpack autodetect). Earlier reports of "the API silently ignoresdockerfilekeys" appear to have been resolved upstream. - Koyeb's GitHub App scoping cannot reach private repos in this account; if a deploy needs git source, it must come from a public repo.
- The CLI
koyebis a fine local tool but the dashboard UI is the source of truth for billing.
Koyeb is intentionally outside OpenTofu
The Koyeb Terraform provider cannot round-trip the live free-tier service safely.
Root cause: the typst service runs with scalings[].min: 0 (scale-to-zero) plus a sleep_idle_delay autoscaling target (the deep-sleep deferral that keeps the free-tier instance hibernated when idle). The Koyeb API returns sleep_idle_delay inside targets[], but terraform-provider-koyeb does not model it in autoScalingTargetSchema() (only average_cpu, average_mem, requests_per_second, concurrent_requests, request_response_time are present). The TypeSet write fails when the unknown field round-trips through the schema, killing the plugin mid-import. Tracked upstream as terraform-provider-koyeb #74 "Supporting pausing/scaling to zero via terraform".
Other SDK fields the provider does not model (would surface as drift after import, even if #74 were fixed): strategy.type (deployment strategy, e.g. ROLLING), mesh, proxy_ports, config_files.
Until #74 lands and the deep-sleep target becomes representable, Koyeb stays out of the fleet OpenTofu roots. Provisioning and lifecycle for the typst service live in:
| Concern | Source of truth |
|---|---|
| Deploy from source | GitHub push to julian-corbet/typst-compile master (auto-deploy on push) |
| Service definition | Koyeb dashboard or PATCH /v1/services/{id} against the REST API |
| Image build | tools/typst-compile/Dockerfile in this repo (mirrored to julian-corbet/typst-compile) |
| App ID / Service ID | App b9d2270b-4ef1-4439-904e-03a3e844a761, Service c3ace6de-8a47-4e0b-807c-2e33c121bfc0, Org 042c5601-ea4e-46fd-b98a-b573dcc3fdd9 |
| Auto-assigned hostname | typst-corbet-consulting-992944d3.koyeb.app |
| Re-creating from scratch | tools/typst-compile/README.md documents the deploy procedure |
Re-evaluate OpenTofu adoption when either of the following lands:
terraform-provider-koyebaddssleep_idle_delaytoautoScalingTargetSchema()(issue #74), OR- The Koyeb API gains a way to suppress
sleep_idle_delayin service Read responses.
CrateDB — shared knowledge store
CrateDB Cloud (CRFREE) stores JobCache Core's shared knowledge layer: roles, ads, organizations, evidence, embeddings, reusable work cache entries and adapter runs. It also stores only the purpose-scoped pseudonymous participant facts and projections admitted by the two privacy seams; pseudonymous data is personal data, not “impersonal.” Product-local workspace/session identifiers, inverse maps, CVL/CL content, notes, files and live edge state stay in D1/R2. (CrateDB replaced CockroachDB as the hot DB in the 2026-06 cutover — see CRATEDB-MIGRATION.md; the Cockroach cluster is dormant, rollback only.)
Hard rules for CrateDB:
- No direct workspace-facing API. Workspaces see resolved job projections from the web Worker, not role/ad rows.
- Reducer-gated aggregation only. Workspace facts move upward only through named reducers in code.
- Expensive work must use stable cache keys. Scrape, extraction, embedding, geocoding, route lookup, and matching should be reused whenever the input key matches.
- Vector indexes are optional operational steps. The base schema can store embeddings; vector indexes are applied separately after the cluster is ready.
Alwaysdata — protected JobCache Core host
Alwaysdata runs the bundled Node 24 JobCache Core at the protected /core
origin. The public App API and MCP peer run on Cloudflare Workers; Core never
receives App session_id state. OpenTofu adopts the site, reconciles a
non-secret launcher and a mode-0600 runtime file, and keeps the provider-visible
site environment empty. Artifact promotion verifies SHA-256 before atomic
replacement.
The obsolete Render service remains suspended and has no JobCache App domain. No paid Render or Northflank activation is part of the architecture.
Hard rules for Core hosting:
- No private workspace/session data. Core receives strict public lookup input, purpose-scoped pseudonyms and verified lifecycle envelopes.
- Scoped credentials. The Crate runtime principal is schema-scoped; control, participant lifecycle and CareerVector erasure-worker capabilities are pairwise distinct.
- Bounded ingress. The Node adapter rejects request bodies above 2 MiB before Hono buffering, including chunked uploads.
GitHub — code hosting (free tier)
Repository ownership is operator-managed. Authenticate gh locally or CI with
the minimum repository scopes required for the operation.
Free for public repos and a generous allowance for private. No infra-cost concerns. Mentioned here only because Koyeb deployments source from GitHub repos.
Other authenticated providers — not currently used in the production path
Potential operator-configured providers:
- Google Cloud (
gcloudCLI) — has free-tier compute (Cloud Run: 2M req/month, WebSocket-capable). Not currently used by CareerVector. Available as future fallback if Deno + CF combination ever proves insufficient. - Scaleway (
scwCLI) — EU-anchored serverless, free tier exists. Same status: future fallback option.
The split-vendor pattern
┌──────────────────────┐
│ Browser │
│ │
│ RelayWalker walks │
│ PUBLIC_RELAY_URLS │
│ [cf, deno, …] │
└──┬───────────────┬───┘
│ │
REST/HTTP │ │ WebSocket (Yjs ops, signaling, awareness)
reads+writes│ │ to whichever relay entry the walker is on
│ │
┌──────▼─────┐ ┌────▼─────────────────────┐
│ Cloudflare │ │ cf : Cloudflare DO │
│ │ │ (`relay/cf/` worker,│
│ Workers │ │ cf.relay.…) │
│ D1 │ │ deno: Deno Deploy │
│ R2 │ │ (`relay/deno/`, │
│ Cache API │ │ deno.relay.…) │
│ DO │ │ (next entries append │
│ │ │ here without protocol │
│ FREE TIER │ │ changes) │
└────────────┘ └───────────────────────────┘
The structural property: CF holds the data, the relay list holds the connection layer. Every relay entry speaks the same Yjs sync subset. A single-vendor outage on any one relay does not take down both "what is the user's data" and "who else is editing right now" simultaneously.
This is multi-vendor resilience along a meaningful axis — not "primary + backup of the same workload" but N independent platforms that can each carry the connection layer alone, while a third concern (durable data) lives on none of their WebSocket paths.
Why the cf entry sits first
Earlier in the design discussion, "Deno-as-only-entry" was floated. The math kills it for Deno-as-sole-relay:
- 100k DAU × ~2 WS sessions/day × 22 days = ~4.4M sessions/month → 4× over Deno's 1M cap.
- WS connection wall-time → ~150 CPU-hours/month → 10× over the 15h limit.
Deno free tier doesn't fit the steady-state WS load above ~10–20k DAU. CF DO (with V3's load-shedding moves) fits comfortably at 100k DAU.
So the list order is:
cffirst because CF DO has the budget headroom to be the everyday relay.denosecond because Deno's free tier easily covers the rare per-tab failover volume and its failure modes are uncorrelated with CF's.- A third entry would be appended with a new vendor prefix (see V3 §"Vendor-prefix convention").
- Inverting the order would burn Deno's free tier while wasting CF DO's headroom.
Failure modes & degradation
| Scenario | What happens |
|---|---|
| All vendors healthy | REST + walker on cf + edge cache, normal V3 operation |
cf quota burned / DO outage |
Walker advances to deno after open-failure or 2 consecutive abnormal closes → realtime collaboration continues |
cf + edge issue |
Walker advances to deno → realtime continues; D1 reads still served from cache; D1 writes paused until edge clears |
| Cloudflare account-wide issue | REST/D1/R2 down → mutations fail → client buffers in IndexedDB. deno still relays Yjs ops between live peers. Recovery = drain the IndexedDB queue when CF returns. |
| Deno Deploy down | Walker stays on (or returns to) cf → no impact |
cf and deno both down |
Walker cycles, emits relay_cycle_all_failed, no live realtime sync. Data layer (CF Workers + D1) still works. Same user-visible degradation as v1 prod with DO down. |
| Deno and CF Workers both down | Catastrophic. Both providers, independently, with no shared upstream. Probability: minutes/year. |
The combined uptime calculation (assuming CF 99.99% and Deno 99.95%, independent failures) puts "at least one WS path available" at ~99.999995% — under 2 minutes/year of full WS-layer downtime.
V3 architectural disciplines (load-bearing)
These are enforced by the @cv/workspace-client SDK and (eventually) ESLint rules. Breaking any one of them collapses the cost model.
Discipline 1 — All mutations go through applyOp
No direct fetch('/api/workspaces/...'). No direct Y.Doc construction outside the SDK. Anyone touching workspace state imports @cv/workspace-client and calls client.applyOp(subDoc, op).
The SDK handles transport selection (HTTP for non-realtime, WS for live, P2P for direct human↔human), optimistic local apply, no-op-write skipping, batched D1 writes, and ETag-driven cache reads. Bypassing the SDK loses every one of those benefits and silently breaks the cost shape.
Lint rule (chunk G): ban fetch('/api/workspaces/...') outside the SDK. Ban new Y.Doc() outside the SDK and one allow-listed test file.
Discipline 2 — No background polling cadence
Polling is event-driven only:
- Tab opens → 1 read.
- Tab regains focus (visibility-change) → 1 read.
- User attempts an action → 1 read.
- Idle tick every ~5 minutes while focused.
That's ~5–10 reads per active session. Adding a setInterval(poll, 5000) to "make it feel responsive" turns 7 polls/session into ~2000 → 100× cost increase. Lint rule should flag any setInterval that touches the workspace API.
Discipline 3 — Coalesce mutations to logical units before writing
One D1 write per "user finished doing something" — drag-released, form-submitted, paragraph-pause. Yjs ops still emit per keystroke for P2P sync (where they're cheap), but D1 only sees the coalesced unit. The SDK's CoalesceBuffer (250–500ms trailing-edge debounce) does this.
Without this, every keystroke is a D1 row, and a 1-minute typing burst writes 60 rows. Coalesced, it writes ~1.
Cost model at 1M users / 100k DAU
| Resource | Estimated load | CF tier | Monthly cost |
|---|---|---|---|
| CF Workers requests | 100k DAU × ~7 polls/session = 21M/month | Workers free covers 3M/month → Paid ($5 base + $0.30/M overage) | ~$8 |
| CF D1 writes | 100k DAU × ~30 logical mutations = 3M/day | Free tier 50M/day | $0 |
| CF D1 reads (post-edge ETag) | ~5% of polls hit content = 35k/day | Free tier 25B/month | $0 |
| CF Edge cache fetches | 700k/day, mostly local | Free | $0 |
| CF DO requests | Multi-human signaling only, ~50k/month (V3 demoted DO usage) | Free tier ~5M/month, 100× headroom | $0 |
| Cloudflare Calls TURN | ~5–10 GB/month | Free tier ~1k GB/month | $0 |
Deno requests (walker on deno entry after cf fails) |
<1% of total relay traffic during CF-side outage windows = thousands/month | Free tier 1M/month, ~1000× headroom | $0 |
| Koyeb (typst service) | Scale-to-zero, ~minutes/day active | Free tier | $0 |
| Total | ~$8–10/month |
$0.000008/user/month at 1M users. With the V3 squeeze list (optimistic local apply, sub-doc partitioning, zstd-19 compression, skip no-op writes, immutable cache key) applied, this drops further to ~$2–3/month.
The cost shape scales linearly with active multi-human concurrency, not user count. A 10× user growth that keeps the same active-concurrency profile costs ~10× nothing.
What lives where (services & their homes)
| Concern | Repo path | Runs on | Why there |
|---|---|---|---|
| UI app (SvelteKit SSR + browser) | ui |
CF Workers | Edge-rendered, co-located with D1 via service binding |
REST API (/api/workspaces/...) |
api/src/routers/* (Hono on Cloudflare Workers) |
CF Workers | Hono router worker. Service binding to D1 is free + microsecond-latency. |
| MCP server (agent tools) | mcp |
CF Workers | Service-binding to ui Worker for shared D1 access |
| Realtime DO | relay/cf |
CF Durable Objects (free plan; the duration cap was only a worry while staging existed in parallel — now that production is the only deploy, headroom is fine) | Sticky-by-name routing for WS fan-out |
Second relay vendor (deno entry in the relay list) |
relay/deno |
Deno Deploy free tier | CF cannot host non-DO WebSocket; Deno can, free, edge-distributed. Walker advances here when the cf entry fails. |
| Typst PDF compilation | external | Koyeb free tier | CPU-heavy, doesn't fit Workers' 50ms CPU/req limit |
| Workspace client SDK | lib/workspace-client |
npm package, runs in browser/agent | Single source of truth for "how to talk to a workspace" |
| Domain models | lib/domain |
npm package | Schemas, ops catalog, shared between ui/relay-cf/mcp |
| Op definitions | lib/mutations |
npm package | Op catalog, applied client-side and server-side |
| Importer (one-shot) | lib/importer |
local dev only | v1 → v2 data migration tool |
Hard rules — summary
- Free tier only, every vendor, every time. No exceptions. Paid is for headroom, never the floor.
- Koyeb is full. The free slot is
typst. Never deploy a second service to Koyeb. cfsits first inPUBLIC_RELAY_URLS,denosecond. Deno's free tier cannot absorb being the everyday entry; CF DO has the headroom. Inverting the order burns Deno and wastes CF DO headroom.- No direct workspace mutations. Everything goes through
@cv/workspace-client. - No background polling. Event-driven only.
setIntervaltouching the workspace API is a code smell. - Coalesce mutations to logical units. One D1 write per user-meaningful action, not per keystroke.
- Multi-vendor along a meaningful axis. Data on CF, connections on the relay list (
cf,deno, …). No single-vendor outage takes both down. - Wire format is portable. No vendor-specific framing on the WebSocket protocol. Same Yjs sync frames work on every relay entry.
If a proposal violates any of these, reject it. If reality and these rules disagree, fix reality immediately.
Companion-perspective subdomains — one worker per perspective
Every companion perspective runs as its own Cloudflare Worker. None of the
perspective paths exist on the main careervector-ui worker — visiting
careervector.corbet.ch/status, /operations, /testing, /status/jobcache,
or /operations/jobcache returns 404. Those path slots are intentionally
free for any unrelated future use.
| Subdomain | Worker | Workspace | Audience |
|---|---|---|---|
status.careervector.corbet.ch |
careervector-status-ui |
status/ui/ |
Public — CareerVector system health |
api.status.careervector.corbet.ch |
careervector-status-api |
status/api/ |
Public — CareerVector status data plane |
mcp.status.careervector.corbet.ch |
careervector-status-mcp |
status/mcp/ |
Public — MCP wrapper of status api |
ops.careervector.corbet.ch |
careervector-ops-ui |
ops/ui/ |
Admin — CareerVector cockpit UI |
api.ops.careervector.corbet.ch |
careervector-ops-api |
ops/api/ |
Admin — CareerVector ops data plane |
mcp.ops.careervector.corbet.ch |
careervector-ops-mcp |
ops/mcp/ |
Admin — MCP wrapper of ops api |
qa.careervector.corbet.ch |
careervector-qa-ui |
qa/ui/ |
Admin — CI evidence + quality ledger |
api.qa.careervector.corbet.ch |
careervector-qa-api |
qa/api/ |
Admin — CareerVector QA data plane |
mcp.qa.careervector.corbet.ch |
careervector-qa-mcp |
qa/mcp/ |
Admin — MCP wrapper of QA api |
status.jobcache.corbet.ch |
jobcache-status-ui |
jobcache/status/ui/ |
Public — JobCache health |
api.status.jobcache.corbet.ch |
jobcache-status-api |
jobcache/status/api/ |
Public — JobCache status data plane |
mcp.status.jobcache.corbet.ch |
jobcache-status-mcp |
jobcache/status/mcp/ |
Public — MCP wrapper of status api |
ops.jobcache.corbet.ch |
jobcache-ops-ui |
jobcache/ops/ui/ |
Admin — JobCache cockpit UI |
api.ops.jobcache.corbet.ch |
jobcache-ops-api |
jobcache/ops/api/ |
Admin — JobCache ops data plane |
mcp.ops.jobcache.corbet.ch |
jobcache-ops-mcp |
jobcache/ops/mcp/ |
Admin — MCP wrapper of ops api |
qa.jobcache.corbet.ch |
jobcache-qa-ui |
jobcache/qa/ui/ |
Admin — JobCache QA evidence |
api.qa.jobcache.corbet.ch |
jobcache-qa-api |
jobcache/qa/api/ |
Admin — JobCache QA data plane |
mcp.qa.jobcache.corbet.ch |
jobcache-qa-mcp |
jobcache/qa/mcp/ |
Admin — MCP wrapper of QA api |
api.jobcache.corbet.ch |
live jobcache-api Worker |
jobcache/api/ |
Public App API; owns its Core credentials and session-private EU D1 |
mcp.jobcache.corbet.ch |
live jobcache-mcp Worker |
jobcache/mcp/ |
Public thin MCP peer over a private App service binding; owns no Core credential |
jobcache.corbet.ch |
reserved | future jobcache/ui/ |
Public observer UI over the same App API; deliberately deferred |
Each worker is a minimal SvelteKit + @sveltejs/adapter-cloudflare app
deployed via wrangler versions upload && wrangler versions deploy. Each
binds only the Cloudflare resources it actually needs (CV_DB and CV_BUCKET
for the CareerVector perspectives; just env vars for the JobCache ones).
Admin workers — every ops-{ui,api,mcp} and qa-{ui,api,mcp} for both
products — hold ADMIN_TOKEN as a per-worker secret. The same value
is set on every admin worker so a single sign-in carries across products
via the shared .corbet.ch-scoped admin_session cookie.
Shared code (D1 health reader, JobCache HTTP probe, bearer auth gate, ledger
readers) lives in @cv/perspective-shared (lib/perspective-shared/).
Every perspective worker imports from there; nothing is duplicated across
worker source trees.
Subdomain → worker bindings live as Cloudflare Workers Custom Domains
(account-managed, PUT /accounts/{account}/workers/domains with
override_existing_origin: true when rebinding). There are NO CF Transform
Rules in this routing path anymore — every perspective worker serves at /
directly. The previous transform-rule + reroute-hook + cache-bypass-plugin
stack has been removed entirely.
Canonical Cloudflare and JobCache domain state lives in the fleet infra
repository. cloud/tofu/cf-jobcache/ owns the EU App D1 and both live Worker
custom domains; cloud/tofu/alwaysdata-sporewright/ owns the Core site and
protected runtime reconciliation. Both workspaces converge to zero-change
plans. Do not invent resource IDs in application configuration.
Why every perspective is its own worker
Earlier iteration tried to serve all perspectives from careervector-ui
under host-based rewrites. That model required a Transform Rule per
subdomain, a SvelteKit reroute hook, and a vite-time patch to bypass the
Workers Cache API for subdomain roots. Every layer had its own failure
mode. It also conflated "perspective" (an architectural boundary) with
"path on the main app" (a presentation choice). Splitting each perspective
into its own worker eliminates all the routing glue, frees every collision
path on the main domain, and matches the perspective abstraction directly.
Cost: each worker has its own deploy cycle. Shared TS code lifted into
@cv/perspective-shared keeps that cost flat.
Post-deploy verification
TOKEN="$(grep '\*\*Token:\*\*' ~/.agent/secrets/cv-ops-admin.md | awk -F'`' '{print $2}')"
# Main domain — app works, every perspective slot is free (404)
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/ # 200
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/status # 404
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/operations # 404
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/testing # 404
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/status/jobcache # 404
curl -s -o /dev/null -w "%{http_code}\n" https://careervector.corbet.ch/operations/jobcache # 404
# Public perspectives
curl -s -o /dev/null -w "%{http_code}\n" https://status.careervector.corbet.ch/ # 200
curl -s -o /dev/null -w "%{http_code}\n" https://status.jobcache.corbet.ch/ # 200
# Admin perspectives — 401 without token, 200 with
curl -s -o /dev/null -w "%{http_code}\n" https://ops.careervector.corbet.ch/ # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://ops.careervector.corbet.ch/ # 200
curl -s -o /dev/null -w "%{http_code}\n" https://qa.careervector.corbet.ch/ # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://qa.careervector.corbet.ch/ # 200
curl -s -o /dev/null -w "%{http_code}\n" https://ops.jobcache.corbet.ch/ # 401
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" https://ops.jobcache.corbet.ch/ # 200
If you need to remove a subdomain
# List bindings (filter to careervector-ui)
curl -s -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_API_KEY" \
"https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/workers/domains?service=careervector-ui"
# Delete by domain id (from the LIST response)
curl -s -X DELETE -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_API_KEY" \
"https://api.cloudflare.com/client/v4/accounts/$ACCT_ID/workers/domains/<DOMAIN_ID>"
Account ID + zone ID are in ~/.agent/secrets/cloudflare.md. Use the global API
key for the Workers Domains endpoint — the scoped agent token only has DNS
write permission, not Workers:Edit.
Cross-references
REALTIME-DESIGN.md— broker contract for the relays (byte-relay mechanics).REALTIME-ARCHITECTURE-V3.md— multi-pathway resilience model + cost model.- Operator runbooks outside this repository — vendor accounts, secrets index, machine inventory, Koyeb deployment gotchas, and Cloudflare CLI safety rules.