Op Catalog — typed writes, one broker contract
lib/mutations/src/opsCatalog.ts is the single source of truth for every
write CareerVector can make to a workspace. Anything that mutates Yjs state
flows through it: the SDK, the realtime worker, the MCP server, and the CF
Worker all import the catalog so they agree on the contract.
Catalog version
OP_CATALOG_VERSION is a monotonically-increasing integer. It bumps whenever
an op is added, removed, or its payload shape changes incompatibly.
- Clients embed the version at build time.
- The broker compares the version on connect and rejects stale clients with a
clear
unknown_op_kindorversion_mismatcherror. - The op kind table below is current at version 8.
The op kinds
| Group | Op kinds |
|---|---|
| Jobs | job.create, job.update, job.delete |
| Node tree | node.create, node.update, node.delete, node.move, selection.update |
| CVL actions | cvl.duplicate_as_variant, cvl.break_out, cvl.promote_sub, cvl.demote_bullet |
| Tailored | tailored.snapshot, tailored.promote |
| Settings / layout | settings.update, layout.update, order_state.update |
| Profiles | cv_profile.replace, cl_profile.replace |
| Translation | translate |
| Atomic composition | batch (intra-sub-doc or cross-sub-doc) |
Canonical sub-doc shape
The workspace is partitioned into independent Y.Doc sub-docs, persisted as
workspace_sub_doc rows. The op group on the left of the table maps to
the sub-doc on the right:
| Sub-doc | Write frequency | Contents |
|---|---|---|
settings |
Rare | Kanban, views, custom columns, scoring, AI settings, theme colors. |
layout |
Occasional | Column visibility, widths, layout overrides. |
order-state |
Frequent | Column order, view group order, manual job order. |
jobs |
Per-job | The job rows; one jobs.<jobId>.* block per job. |
cv_profile.<lang> |
User edits | Master CV content quarry per language. |
cl_profile.<lang> |
User edits | Master CL content quarry per language. |
notes |
Per-job notes | Free-form workspace notes. |
cloud_keys |
Rare | BYOK keys, cascade chains. |
Each sub-doc has its own clock and its own edge cache key, so frequent
order_state.update writes do not invalidate the rare-write settings edge
cache. Per-sub-doc partitioning is what keeps reads cheap.
Canonical CVL shape
A CV or cover letter is a recursive Node tree. Every node carries a pool of
children[] (active and inactive candidates) and a selection.active[] of
child IDs that render. Top-level section variants live inside variant-pool
wrapper Nodes; the wrapper's selection picks the rendered member, the
parent's selection controls whether the wrapper renders at all.
The contract for writers is strict:
node.createadds to the pool only. To make a new node render immediately, follow withselection.updateon the parent.- Inactive children are intentional quarry memory. Never delete, flatten, or reorder them as cleanup unless the user explicitly asks to remove that variant.
- High-level editor intents dispatch through the
cvl.*action layer. Each one expands into abatchofnode.*andselection.updatesub-ops inside one Yjs transaction, so peers never observe a half-applied duplicate or break-out.
This shape is canonical for both cv_profile and cl_profile sub-docs.
There is no "personal info section" special case, no flat list shortcut.
Everything is a Node.
Raw jobs sub-doc shape
The jobs sub-doc holds one block per job, keyed by jobId. Standard keys
under jobs.<jobId>:
attributes— extracted facts (title, company, location, salary, industry, etc.) and their per-key origin.evaluations— scores keyed by custom-column id.eval_reasoning— LLM-written rationale per evaluation.tailored_cv,tailored_cl— snapshot Node trees taken at tailor time.score,score_details, timestamps — always system-written.
Cell origins are derived from this sub-doc's op log; see CLAUDE.md §13.
The batch op
batch collapses the legacy node.batch and workspace.batch ops into one
primitive. Sub-ops carry { subDoc?, op }. The server's behaviour depends
on whether the sub-ops resolve to one sub-doc or several:
- One sub-doc: one Y.Doc transaction, one CAS UPDATE, one op-log INSERT, one snapshot bump, one broadcast.
- Multiple sub-docs: each group applied through its own Y.Doc; CAS
UPDATEs committed via
env.DB.batch([...])(D1 batches are atomic); per-sub-doc broadcasts fan out only after the batch commits.
Use client.applyOp(sub, batchOp) for the single-sub-doc shape (sub-ops
inherit sub as their primary) or client.applyBatch(ops) for cross-sub-
doc batches (every sub-op MUST carry an explicit subDoc). Either path
bypasses the per-sub-doc CoalesceBuffer because the batch is itself the
atomic boundary.
Unknown op handling
applyOp(doc, op) rejects unknown op kinds with unknown_op_kind and
includes the current OP_CATALOG_VERSION in the error. A stale client gets
a clear "you are behind the broker" message instead of silently writing
nothing.
Why the catalog and not free-form writes
- Contract. Every actor (browser SDK, MCP server, CF Worker, agent runner) imports the same module and sees the same shape.
- Type safety. The catalog is Zod-typed; the broker validates before applying, so bad ops never reach Yjs state.
- Versioning. One integer governs broker/client compatibility.
- Auditability. The op log mirrors the catalog one-to-one; the cell-origins projection works because each op kind is documented.
Free-form doc.transact(...) writes outside the catalog are not allowed.
If a new gesture needs a primitive the catalog does not have, add the op
to the catalog, bump OP_CATALOG_VERSION, and ship it everywhere at once.