CareerVector — Architecture
Technical architecture decisions that flow from the business model in
studies/business-model/README.md. Covers the data model, system surfaces
(Browser UI / REST API / MCP), the relationship between CareerVector and
jobcache, and the shape of workspace state.
The business-model conclusions are the constraints this work operates under. They are not re-derived here.
Collaborator model
Inside a workspace, every human collaborator is a peer. No helper / seeker distinction at the architectural level, no per-user permissions, no role assignment, no separate UI modes.
Three existing surfaces handle the cases where gradation might seem to matter:
- Read-only view for someone who wants to look without editing.
- Workspace copy / fork for someone who wants a workspace as a starting point for their own.
- Full collaborative edit access for everyone with the workspace link.
The trust gate happens at link-sharing time, not at the platform. The platform does not model trust gradations: no user wants permission UI, and sharing the link is itself the binary trust decision. You wouldn't share with someone you don't trust.
Data Model: Three Sparse Rungs
CareerVector has one logical fact tree split across two databases:
Cockroach role shared role-level facts, hidden from workspace APIs
Cockroach ad shared posting-level facts, hidden from workspace APIs
D1/Y.Doc workspace job private workspace rung, user-facing
Each rung can hold attribute values. Storage at each rung is sparse: a rung only records a value when it differs from what it inherits.
role
│
└─ ad
│
└─ workspace.jobs[job_id]
What Each Rung Holds
role is the shared role-level node. It holds the platform's best current
role facts across all known ads: title, organization, place, salary range,
employment type, seniority, language, description, embedding, and timestamps.
It lives in Cockroach.
ad is one URL where a role appears. It carries ad_id, url, role_id,
source, scrape metadata, and sparse facts that differ from the role node. It
lives in Cockroach. jobcache creates and refreshes ads from the top down;
workspace imports create or reuse ads from the bottom up.
workspace.jobs[job_id] is the private workspace record for a tracked job. It
holds status, evaluations, custom columns, tailored CV/CL references, local
facts, and overrides. It lives in D1/Y.Doc.
CVL/CL quarry documents are part of the private workspace rung. They are edit-heavy and collaborative, so their live bodies stay in D1/Y.Doc. Cockroach may later hold derived candidate artifacts or indexes the product is allowed to use, but not the live editing surface.
Read Chain
Workspace APIs return only the resolved workspace projection:
function readAttribute(workspace, job_id, attr):
if workspace has attr for job_id:
return workspace value
if ad has attr:
return ad value
if role has attr:
return role value
return absent
Workspace-facing humans, agents, browser workers, and MCP tools do not query role or ad nodes directly.
Sparsity And Compression
- The role rung pays once for facts that compress across ads.
- The ad rung pays only for facts that differ from the role rung.
- The workspace rung pays only for private state and facts that differ from the shared projection.
Absence at the workspace rung means "inherit the shared projection." It is not unknown and not a disagreement.
D1 may also compress horizontally among workspaces. If one ad has a shared value and many workspace-local variants, D1 can summarize repeated private values without copying raw workspace memberships into Cockroach.
ID landscape
| Entity | ID | Derivation | Notes |
|---|---|---|---|
role |
role_id |
Initially: same value as the first ad_id (= hash of first normalized URL). Future matching may point multiple ads at one role_id. |
Shared role-level node. Hidden from workspace-facing APIs. |
ad |
ad_id |
Hash of normalized URL. Stable forever. | 1:1 with URL. |
workspace job |
job_id |
Workspace-local tracking id. Today it starts as the URL-derived ad_id; later allocation can diverge. |
User-facing identity. |
workspace |
workspace_id |
The workspace slug (existing). | IS the candidate. No separate candidate_id. |
org |
org_id |
Hash of normalized company name + alias-map resolution. Imperfect today; improves over time with curated aliases and vector similarity. | The hard ER problem. |
place_key |
From location.ts normalization (e.g. ZRH,CH). |
Existing infrastructure. Already handles multilingual country names, airport-code city identifiers. | |
skill_id |
None. Skills are amorphous; vector embeddings handle similarity. | No taxonomy. | |
industry_id |
Existing enum (CLAUDE.md §16). | Stays. |
URL normalization rules are part of the API contract — change them and
ad_id / initial role_id values shift retroactively, breaking dedup.
Lock them and treat them as immutable.
Storage Layout
Shared: Cockroach / jobcache
role[role_id] → { shared attributes, embedding, timestamps }
ad[ad_id] → { url, role_id, source, scraped_at, sparse attribute overrides }
org[org_id] → { shared name, aliases, country_code, industry }
work_cache[key] → { kind, input_hash, output_ref, status, timestamps }
aggregate[id] → { reducer, cohort, output, threshold metadata }
Private: D1/Y.Doc Workspace Rung
workspace.jobs[job_id] → {
status, evaluation, eval_reasoning, application_notes,
custom_columns,
tailored_cv, tailored_cl,
overrides: { attr_key: value, ... } // sparse
}
workspace.cv_profile.<lang>.tree // master CV quarry per language
workspace.cl_profile.<lang>.tree // master CL quarry per language
workspace.settings // preferences, scoring weights, custom columns, chat
workspace.layout
workspace.order_state
workspace.notes
Data Flow: D1 ↔ Cockroach
The relationship is bidirectional, but private data does not leave D1 by default.
D1 → Cockroach
What flows up:
- Ad ensure. A workspace imports a URL. D1 records the workspace action and queues an idempotent command. Cockroach creates or reuses the ad/role.
- Shared extraction output. If a workspace produces a scrape/extraction for a public ad, jobcache may store the public ad/role facts and work-cache result in Cockroach.
- Approved aggregate output. D1-derived analytics cross the boundary only through named reducers with thresholds and output schemas.
- Candidate artifact/index. CVL-derived candidate material enters Cockroach only when the product is allowed to use it for discovery or brokered contact. The live quarry remains in D1/Y.Doc.
What never flows up:
- Raw per-workspace evaluations.
- Workspace IDs coupled to anything reverse-linkable.
- Live CVL/CL quarry bodies.
- Notes, comments, BYOK keys, and workspace identity.
- Anything failing the trust test from the business model.
Cockroach → D1
What flows down:
- Shared role/ad projection. Workspace rendering pulls the shared ad/role projection and resolves it with private D1 values.
- Recommendation feed. jobcache surfaces roles/ads the workspace may want to track. The workspace decides whether to add the job.
- Similarity / matching. "Jobs similar to this one" → nearest- neighbor over shared embeddings.
- Work-cache hit. When a workspace imports a URL jobcache already knows, D1 can create a workspace reference without re-scraping or re-extracting.
Cross-Database Writes
D1 and Cockroach are not one transaction. Cross-boundary writes use an outbox:
- D1 commits the workspace action.
- D1 records a pending command with a stable id and input hash.
- A worker/jobcache process applies the command to Cockroach.
- D1 stores the returned
ad_id, projection hash, or failure state.
If Cockroach is unavailable, the workspace keeps working from D1 with the last known shared projection.
Ad Grouping
When jobcache determines that two ads represent the same role (for example, a
LinkedIn URL and a company-careers URL for the same role), it points both ads
at one role_id. This is a shared decision made from description similarity,
employer match, dates, and other signals.
Workspace-side, ad grouping is a hint, not an action. The platform does not silently merge tracking records. If a workspace tracks two ads that later point at the same role, the UI can surface a soft hint. The user decides whether to consolidate. Multiple ads for the same role in one workspace are allowed.
Reparenting an ad under a different role must preserve every workspace projection unless the operation is explicitly a synthesis update.
Tailored CVL Placement
The per-job tailored CV+CL lives at workspace.jobs[job_id].tailored_cv /
.tailored_cl — per tracked job. Tailoring uses the ad's job description (via
the inheritance chain) plus the workspace's quarry in the job's
language.
If a workspace tracks two ads that turn out to be the same role, two tailored documents may exist. The platform does not auto-dedupe them. The user may want different tailored documents for different ads.
Privacy Through Structure
Privacy is enforced by where data lives, not by access-control logic:
role,ad,org,work_cache, shared evidence, embeddings, and approved aggregate outputs live in Cockroach.- Workspace facts, evaluations, custom columns, CVL/CL, notes, settings, and local summaries live in D1/Y.Doc.
- Raw private workspace data does not enter Cockroach by default.
- Only named reducers/exporters may move information from D1 to Cockroach.
This is the structural moat for the squeaky-clean commitment from the business model: the shared business asset is Cockroach, and the private workspace production environment is D1.
What's Not In This Architecture Yet
These are downstream design decisions deferred until the relevant work becomes load-bearing:
- Recruiter / buyer surface. No code exists yet. The data model supports it through Cockroach: ad/role corpus, candidate artifacts or indexes the product is allowed to use, and approved aggregate outputs.
- Discovery product policy. The storage boundary is decided, but the product policy for broad candidate discovery is not.
- Vector index details. Cockroach is the current target because the jobcache cluster already exists and supports vectors. Exact index strategy is still implementation work.
- org_id resolution upgrades. Today: hash of normalized name + small alias map. Future: vector-similarity matching, external authority lookups. Improvements don't require migration.
- Aggregate reducers. Cadence, granularity, thresholds, bucketing, and suppression rules are open.
- Ad grouping implementation. Both the algorithm and the user-facing hint UI remain open.