Architecture
The shape of the active CareerVector codebase.
Stack at a glance
| Layer | Choice | Why |
|---|---|---|
| Front-end framework | Svelte 5 | Smallest bundle, fastest runtime, smallest per-component scope (good for AI agents to write). Compiler optimizes reactivity. |
| Meta-framework | SvelteKit | First-class Cloudflare Workers adapter, file-based routing, server functions, conventional. SPA-shape with optional server bits. |
| Build | Vite (via SvelteKit) | Standard, fast, well-supported by SvelteKit. |
| Backend | Cloudflare Workers | Same compute primitive, no Pages adapter wrappers. Free tier. |
| Realtime broker | Durable Object per workspace | Thin byte relay for live collaboration. Durable writes go through the api worker first. |
| Private workspace data | D1 + Yjs | D1/Y.Doc owns the workspace rung: CVL, CL, workspace facts, evaluations, local summaries. |
| Shared knowledge data | CockroachDB | Shared role/ad facts, jobcache, work cache, embeddings, matching, evidence, approved aggregate outputs. |
| Blob storage | R2/B2 | Typst PDFs, raw scrape snapshots, uploads, and other large immutable blobs. |
| LLM access | Vercel AI SDK in browser (cascade) | Client compute = free. BYOK + free-tier-preference + masked keys remain. |
| Document rendering | Typst WASM in browser | Client compute = free. WASM cached in Service Worker for repeat visits. |
| Agent API | MCP server as separate Worker | Wraps REST as MCP tools. Agents (Claude Code, etc.) connect, drive the tool same as humans. |
| Tests | Playwright (e2e) + Vitest (unit) | Same as v1, behavior-test-first discipline. |
Top-level directory layout
The repo IS CareerVector by name. Top-level peers are CV's perspectives (ui/mcp/wiki/status/ops/qa…) + libraries + the JobCache peer product + tools + R&D + knowledge.
careervector-cvl/
├── ui/ ← perspective: human, interactive (SvelteKit)
├── mcp/ ← perspective: AI agent, MCP protocol
├── relay/cf/ ← infra: WorkspaceDoc DO (thin byte-relay supporting ui/)
├── relay/deno/ ← infra: Deno Deploy fallback (vendor-resilience)
├── relay/shared/ ← @cv/relay-shared: wire constants + workspace-id parser
├── lib/ ← CareerVector-internal libraries
│ ├── domain/ ← @cv/domain — LLM helpers, scoring, tailoring, Typst serializer, cascade
│ ├── schemas/ ← @cv/schemas — Zod (Node tree, jobs, settings, format)
│ ├── mutations/ ← @cv/mutations — op catalog + apply/validate
│ ├── workspace-client/ ← @cv/workspace-client — SDK for ui/MCP write paths
│ ├── health/ ← @cv/health — Health/Check/Metric cross-perspective state shape
│ └── importer/ ← @cv/importer — workspace import logic
├── jobcache/ ← peer product (shared role/ad corpus; rebrand-ready)
├── experiments/
│ └── cvl-engines/ ← active R&D: Node-tree CRDT alternatives (loro, yjs, …)
├── tools/ ← infra utilities, NOT products
│ ├── quality/ ← Rust gate/ledger/policy core
│ └── typst-compile/ ← Koyeb-hosted Typst PDF service
├── wiki/
│ └── content/ ← architecture, vocabulary, runbooks, studies, this file
├── archive/ ← retired knowledge (handoffs, superseded plans)
├── migrations/ ← CareerVector D1 SQL migrations
└── scripts/ ← repo-level utilities
lib/domain is the framework-agnostic core: scoring, conventions, Typst Node serializer (nodeToTypst), the LLM cascade, the tailor pipeline. lib/mutations owns the op catalog (OP_CATALOG_VERSION) that ui, MCP, and the relays all share.
Data layer in detail
Y.Doc structure per workspace
CV/CL content is stored as independent peer trees, one per language. There is no cross-language inheritance; each language tree is complete for that language. See TREE-OF-CHOICES.md for the full design rationale.
D1/Y.Doc owns the private workspace rung. Raw workspace jobs are public
{ url, attributes } entries. Cockroach owns shared role/ad facts and jobcache.
Workspace-facing APIs, UI, browser workers, and MCP expose job terminology and
resolved job projections; they never expose direct role/ad access or raw
ad_id / role_id values. Hidden shared ids live only in server-side
link/outbox/shared-corpus storage.
workspace.doc (Y.Doc — partitioned into sub-docs persisted as workspace_sub_doc rows)
├── settings: Y.Map // workspace-global settings (kanban, scoring, AI prefs)
├── layout: Y.Map // column visibility, widths
├── order_state: Y.Map // column / group / job manual ordering
├── cv_profile: Y.Map<lang, Y.Map>
│ ├── en: Y.Map // English peer tree; complete and authoritative
│ │ ├── tree: Y.Map // recursive Node tree (children[] + selection.active[])
│ │ └── format: Y.Map
│ └── de: Y.Map // German peer tree; independent of en
│ ├── tree: Y.Map
│ └── format: Y.Map
├── cl_profile: Y.Map<lang, Y.Map> // same per-language peer-tree shape as cv_profile
├── jobs: Y.Map<jobId, Y.Map> // public workspace jobs: { url, attributes }
│ └── {jobId}: Y.Map
│ ├── url
│ └── attributes: Y.Map
│ ├── title, organization, etc.
│ ├── tailored_cv: Y.Map // full Node-tree snapshot taken at tailor time
│ ├── tailored_cl: Y.Map // full Node-tree snapshot taken at tailor time
│ ├── evaluations: Y.Map
│ └── origins: Y.Map
└── cloud_keys: Y.Map // BYOK + chain config
Key properties of the tree shape:
tree: Y.Mapis the recursiveNodeshape: every node haschildren[](the pool of candidates) andselection.active[](the ordered subset that renders).- Top-level section variants live inside
variant-poolwrapper Nodes. The wrapper'sselection.active[]picks the rendered member; the parent'sselection.active[]decides whether the wrapper renders at all. tailored_cv/tailored_clare full tree snapshots taken at tailor-time from a single language tree. Each application diverges independently after the snapshot.- Each non-English language tree is independent. Per-language cultural differences (section ordering, omitted sections, distinct content) are handled at the tree level, not via per-node language filters.
- Node IDs are nanoids; Yjs handles convergence.
Persistence
D1 holds:
workspace_sub_docupdate bytes for private workspace docs (settings,cv-profile,cl-profile,jobs,notes).- Server-side link/outbox rows from workspace job IDs to hidden shared ad/role records.
- Workspace metadata (id, slug, name, created_at).
- Operational ledgers such as process requests and compute locks.
- Workspace-local summaries and projection caches.
The relay workers hold no durable Y.Doc authority. They relay update bytes for live peers after the api worker has committed them to D1.
Cockroach holds:
role,ad,orgshared facts.- jobcache frontier/control rows, work-cache rows, and evidence.
- Embeddings and matching indexes.
- Approved aggregate outputs and candidate artifacts/indexes the product is allowed to use.
R2 holds:
- Old snapshots (older than X days)
- User-exported PDFs (cached if computed server-side, but most exports stay client-side)
- Large blobs
Sync protocol
Standard Yjs WebSocket protocol via y-protocols. Awareness for presence (replaces the v1 upper-right indicator's data path; the indicator UI is unchanged per principle 13).
Reconnecting clients sync via Yjs vector clocks — only missed ops are sent. No full-state refetch.
Compute placement
Following PRINCIPLES.md #1 (compute economics):
| Operation | Where | Why |
|---|---|---|
| Yjs ops application (write) | Client | CRDT primitive, deterministic, free for us |
| Yjs ops broadcast | Realtime worker | Live fanout only |
| Yjs persistence | D1 | Private workspace durability |
| LLM calls (extract, evaluate, tailor) | Client (Vercel AI SDK) | Free for us; BYOK; cascade |
| Typst rendering | Client (WASM) | Free for us; live latency |
| Typst PDF export | Client (WASM, same render pipeline) | Free for us; consistent with preview |
| Workspace creation | Server (Worker) | Trivial DB write, must be server |
| Ad/workspace facts via REST | Server (Worker, thin) | Private workspace writes, plus outbox commands for hidden shared role/ad storage |
| MCP server | Server (Worker, thin) | Per-request, lightweight |
| Image / asset optimization | Client / cache | Free for us |
Workers do as little as possible. Each request well under 10ms CPU.
Routing
SvelteKit file-based, under ui/src/routes/:
ui/src/routes/
├── +layout.svelte // shell
├── +page.svelte // landing / workspace creator
├── [wsId]/
│ ├── +layout.svelte // workspace shell, presence indicator
│ ├── +page.svelte // redirects to /dashboard
│ ├── dashboard/+page.svelte
│ ├── kanban/+page.svelte
│ ├── cvl/+page.svelte
│ └── profile/+page.svelte
└── view/
└── [wsId]/
└── +page.svelte // future read-only mode (CLAUDE.md §2)
API surface
Two kinds of server endpoints:
Read-only views
GET /api/workspaces/:id returns a snapshot of the current Y.Doc state (for fresh client load before WebSocket sync).
Action triggers (idempotent)
POST /api/jobs/:id/tailor — server doesn't tailor (LLM is client-side); this just kicks the right Yjs op into the broker.
The MCP server (separate Worker) wraps these REST endpoints as MCP tools.
Security model
- Link = identity. No auth. Workspace URL = full access.
- API keys (cloud_keys) returned MASKED on
GET /api/workspaces/:id. Real keys only viaGET /api/chain?workspace=which is the execution path used by client LLM calls. - Dev-mode env keys: gated behind
APP_MODE === 'dev'. Production uses BYOK only. - All writes idempotent where possible (agents will retry).
Extension points (for future)
- Read-only mode (
/view/:wsId/*) — same data, all writes disabled globally. Per CLAUDE.md §2. - Multi-locale beyond current 30 — convention table in
lib/domain/src/conventions.tsextends. - Agent activity attribution — Yjs awareness already includes userId; just expose it in the existing presence indicator (don't extend presence UI per principle 13).
- Native mobile — if ever wanted. Yjs has bindings for Swift/Kotlin via Automerge-style. Long-tail.