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_kind or version_mismatch error.
  • 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.create adds to the pool only. To make a new node render immediately, follow with selection.update on 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 a batch of node.* and selection.update sub-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.

Source: wiki/content/canon/op-catalog.md