CRDT-pattern gotchas
A short field guide for anyone writing or reviewing Yjs ops in CareerVector. Each entry pairs a tempting-looking pattern with a counter-example that bit us, and the canonical fix that survived review.
Companion to
REALTIME-ARCHITECTURE-V3.mdandREALTIME-DESIGN.md. Those cover the multi-pathway resilience model and the thin-relay broker contract; this document covers the operational CRDT semantics you have to understand to write correct ops.
1. Y.Map.set is a REPLACE, not a merge
If you write a brand-new sub-map at an existing key, peers do not field-
merge — the higher-clientID entry wins on .get() and the loser's fields
disappear. Two clients that independently jobs.set('7', new Y.Map()) (e.g.
during cold-load) will lose data.
Anti-pattern
// Each client mints its own Y.Map for the same key — last writer by clientID
// wins; the loser's per-field edits become invisible.
const job = new Y.Map();
for (const [k, v] of Object.entries(rowFromD1)) job.set(k, v);
jobs.set(jobId, job);
Fix
- For seeding (cold-load), only run when the entry does not already exist
(
if (!jobs.has(id)) ...). - For updates on an existing entry, mutate the existing sub-map:
jobs.get(id).set(field, value)— same item identity, field-level merge. - For server-originated whole-entry replacements, accept the clobber and use a high-priority clientID so the replacement always wins (see §3).
2. Y.Array.delete + push does not survive cold-load
Treating reorders as delete(idx) + push(item) produces an update whose
correctness depends on the array's prior item identities. Cold-load that
re-creates the array fresh (different item identities) replays as
delete(idx) against an array of different shape — the wrong item is
removed or replay throws. This is the section-reorder bug (commit
cff90977).
Fix: structural changes go through whole-tree replacement.
branchRoot.set('tree', nodeToYMap(...)) — atomic, commutative, and
identical to what cold-load produces.
3. Server-originated ops in thin-relay need a stable, high-priority clientID
In Phase 4 thin-relay, the DO holds no authoritative Y.Doc, so the server has
no authoritative item identity. Naïvely broadcasting a server-side
job.update from a transient Y.Doc with a random clientID:
- Bloats the SV by one slot per REST call (one stale clientID per op).
- Loses the LWW tie when the recipient already has a higher random clientID.
Fix (in relay/cf/src/workspace-doc.ts:stableClientId):
- Stable per-workspace clientID derived via fnv-1a — single SV slot for the "server actor".
- Top of the uint32 range (
0xFFFE_0000 | (hash & 0xffff)) so it always beats any per-browser random clientID in Yjs's last-write-wins-by-clientID tie-breaker. - Whole-entry replacement semantics:
job.updateis promoted to a syntheticjob.createcarrying the post-update workspace job. Race window with concurrent client edits is documented and bounded.
4. Y.encodeStateAsUpdate is NOT canonical
Two Y.Docs holding the logically identical state can produce different
binary updates (different item ordering, different clientID metadata).
For equality checks use a JSON projection (doc.toJSON()); for "did anything
change?" diffs use the state vector and encodeStateAsUpdate(doc, sv).
5. Cold-load gates must be PER-SUBTREE
Using one subtree's emptiness as a proxy for "everything is hydrated"
breaks the moment a different subtree is mutated server-side. The
forms-validation regression was exactly this: cv_profile.size > 0 was
treated as "IDB is complete," dropping the jobs fetch on every reload —
even though jobs is a wholly independent Y.Map root.
Fix: cold-load merges run unconditionally. Each subtree decides internally whether its data is safe to merge:
| Subtree | Merge rule |
|---|---|
cv_profile, cl_profile |
Skip per-lang if already populated (whole-tree replace would clobber). |
settings, layout, order_state |
Skip per-key when populated. |
jobs |
Skip per-id when populated; safely merges new rows. |
6. Snapshots are written by the leader tab snapshot writer
Phase 4's POST /snapshot/:wsId is the only way the D1 y_snapshot column
moves forward. The SnapshotWriter class (snapshot-writer.ts) closes this
loop: the leader tab periodically posts Y.encodeStateAsUpdate(doc) to the
endpoint on a 30-second cadence (while dirty) and on visibilitychange:hidden
/ beforeunload (via navigator.sendBeacon). After the first flush, cold-load
via /snapshot/:wsId returns 200 with the full Y.Doc state, bypassing the
slower coldLoadFromRest fallback.
See §13 for the full design and dirty-flag origin classification.
7. Origin matters for doc.on('update', ...)
If you transact(..., origin) with one symbol but listen for a different
origin, your handler sees nothing — and forgetting to filter origins makes
seed steps re-broadcast (the thin-relay handler trips over this if you do
not split origins).
Pattern for "capture op bytes but not seed bytes":
const opOrigin = Symbol('op');
const seedOrigin = Symbol('seed');
const handler = (update, origin) => {
if (origin === opOrigin) opBytes = update;
};
doc.on('update', handler);
doc.transact(seedDoc, seedOrigin); // seed bytes ignored
doc.transact(applyOp, opOrigin); // op bytes captured
doc.off('update', handler);
8. Flat-map seeding paradox in thin-relay
When a server-side op patches an existing Y.Map (e.g. settings.update), it
is tempting to first seed the prior values into the transient Y.Doc so the
broadcast looks like a minimal delta. Do not do this.
A seed step inserts Yjs items into the doc. The patch op then creates items
whose left/origin reference the seed items' (clientID, clock) pair.
A connected peer (which has a different Y.Doc with different item identities)
receives the broadcast and cannot resolve those references — the update lands
in "pending integrations" and is silently dropped.
Fix: Apply flat-map patches directly to a completely empty transient Y.Doc. The resulting items have no origin references; any peer integrates them unconditionally via LWW. The trade-off is that the broadcast carries only the patched fields, not the prior state — but for LWW flat-map semantics that is correct.
9. Tree-path delta unresolvability in thin-relay
For tree-path ops (node.*, node.batch, selection.update, etc.),
the server must seed the profile tree from D1 before applying the op (so
applyOp can traverse the tree). But the delta bytes reference the seeded
items' identities — which differ from the connected peer's tree items
(attributed to the peer's own clientID from cold-load). The peer cannot resolve
those references and the update is dropped.
Fix (whole-tree-replace promotion): After applying the tree-path op to the
seeded transient doc, extract the mutated tree via treeMap.toJSON(), then
build a fresh Y.Doc and apply a synthetic cv_profile.replace (or
cl_profile.replace). The resulting update bytes are self-contained — no
foreign item references — and any peer integrates them as LWW on the lang key.
This is the same CRDT pattern as job.update -> job.create.
10. Stable clientID clock collision for sequential thin-relay ops
Each thin-relay request builds a fresh transient Y.Doc with the stable
clientID (§3). Each fresh doc resets the internal clock to 0. Two sequential
requests produce two independent frames both starting at clock 0 under the
same clientID. When a peer applies frame A then frame B, both at (X, 0),
Yjs treats B's items as duplicates of A's items and silently skips the second
op.
This means a peer that applies job.create (frame 1) then settings.update
(frame 2) within the same session will not see the settings change — the
settings items clock-collide with the job items.
Current mitigation: Each broadcast frame is independently correct when applied to a fresh peer (which has no prior state for either frame). The collision only manifests when a peer attempts to merge two server-originated frames in a single session. In practice this race window is small (REST writes happen asynchronously with irregular timing), and each individual op lands correctly on a peer that was connected at the time of the broadcast.
Full fix (out of scope for Phase 4): Persist the server's Yjs clock across requests (DO durable state or a D1 counter). Then each thin-relay request can start from the last-known clock, eliminating collision.
11. Cold-load schema completeness
Every D1 column that any component reads from the Y.Doc jobs map must be
returned by GET /api/workspaces/:id and seeded into the Y.Doc by
coldLoadFromRest(). Hardcoded {} or null or simply omitting a field is a
silent regression: the component sees undefined on cold-load and silently
falls back, misbehaves, or displays stale state until a Yjs peer joins.
Pattern for auditing: Compare the JobSeedRow interface and the SQL SELECT
in +server.ts against every field accessed from jobsStore.value in
components and pipeline.svelte.ts. Any field present in the latter but absent
from the former is a gap.
Known regressions and fixes (COLD-LOAD-AUDIT-2026-04-28):
| Column | Fixed in | Impact |
|---|---|---|
jobs[] array entirely absent |
6dae317a |
Dashboard showed zero rows on fresh browser |
origins hardcoded {} |
f74d65e8 |
Cell origin tints missing on cold-load |
commute_config, cl_recipient missing |
241c714a |
Commute display wrong, CL recipient lost |
tailored_cv missing |
2026-04-28 | Docs modal showed wrong status; CL generation ignored tailored CV |
tailored_cl missing |
2026-04-28 | Docs modal CL badge missing |
chain_overrides missing |
2026-04-28 | Scorecard model picker showed wrong value; pipeline used wrong L2 chain |
What does NOT need to be in the cold-load payload:
format_overrides— no component reads from Y.Doc; CvGenerator fetches fresh via individual job endpoint.raw_scrape— pipeline-internal write-only field, never displayed.location_id— server-side join key used by the commute API only.
12. Compute lock TTL must match the task's worst-case LLM duration
Symptom: Two browser tabs both run the same LLM stage for the same job — BYOK key charged twice, conflicting results written to Yjs.
Root cause: The compute_lock table uses a TTL-based expiry. If the TTL is shorter than the actual LLM call, the cleanup sweep that runs on every acquireLock call deletes a still-active lock and lets a second tab steal it.
The tailor stage (CV + CL with 4-concurrent section calls, up to 2 critic-loop iterations, and 2 selection retries per section) routinely takes 90–120 s on slow providers. The old uniform 60 s TTL was too short.
Anti-pattern (uniform 60 s for all tasks):
T=0 Tab A acquires lock for 'tailor' (TTL=60)
T=90 Tab B calls acquireLock
→ cleanup: locked_at (T=0) < now - 60s (T=30) → DELETE Tab A's lock ✓
→ INSERT → Tab B wins
T=90 Tab A is still running its LLM call
Both tabs will persist conflicting tailored documents.
Fix — per-task TTL (Option C):
lib/domain/src/lock.ts exports TASK_TTL:
| Task | Worst-case | TTL |
|---|---|---|
tailor |
~120 s | 180 s |
evaluate |
~90 s | 120 s |
scrape |
~40 s | 60 s |
extract |
~30 s | 60 s |
research |
~20 s | 45 s |
salary |
~15 s | 30 s |
Callers pass TASK_TTL[task] to acquireLock() and include it as ttl_seconds in the POST /api/.../compute-lock body. The server endpoint validates and applies the TTL to the DELETE sweep, then passes it to acquireLock().
Blast-radius trade-off: A crashed tab holding a tailor lock will block new acquires for up to 180 s (vs 60 s before). This is acceptable: tailoring is a one-time per-job event and the next tab can wait. Faster tasks (salary: 30 s) are now tighter than the old uniform TTL, so stale salary locks clear faster.
Tests: lib/domain/tests/lock.test.ts — "TASK_TTL — per-task TTL prevents premature lock expiry" suite, including a regression test that explicitly demonstrates the old 60 s TTL would have stolen the tailor lock at T=90 s.
13. Persistence: leader tab snapshot writer
All Yjs ops MUST eventually reach D1 even if no peer ever issues a REST write. The leader tab (per L0 leader election) writes Y.encodeStateAsUpdate(doc) to POST /api/workspaces/:id/snapshot on cadence (~30s while dirty), visibilitychange:hidden, and beforeunload. Use navigator.sendBeacon for the unload triggers — fire-and-forget guaranteed delivery. Followers detect dirty but don't duplicate POSTs.
Dirty-flag origin classification:
| Origin value | Local? | Rationale |
|---|---|---|
undefined / null |
YES | Bare applyOp from UI, no explicit origin |
Symbol (any) |
YES | Svelte component transaction |
'bc' |
NO | BroadcastChannel relay from another tab |
'snapshot' |
NO | coldLoadFromSnapshot binary apply |
'cold-load' |
NO | Alias for snapshot applies |
'rest-seed' |
NO | coldLoadFromRest seeding |
| any object | NO | WorkspaceConnection instance (WS/WebRTC origin) |
| any other string | NO | Conservative fallback |
Clock header: X-Snapshot-Clock = sum of all client clock entries in the state vector. The server's conditional write (y_snapshot_clock < incoming) prevents stale snapshots from overwriting newer ones in the concurrent-leader-flush race. For sendBeacon (no custom headers), the clock is passed as ?clock=N in the URL.
Implementation: ui/src/lib/realtime/snapshot-writer.ts — SnapshotWriter class.
Integration: ui/src/lib/realtime/connection-impl.ts — instantiated in WorkspaceConnection.connect().
Tests: ui/src/lib/realtime/snapshot-writer.test.ts.
Endpoint: ui/src/routes/api/workspaces/[id]/snapshot/+server.ts (POST).
E2E: ui/e2e/persistence-snapshot.spec.ts.
Snapshot writes happen client-side via the leader-tab SnapshotWriter; the broker no longer needs a write path for snapshots beyond accepting the bytes.
Reference implementations
| Concern | File |
|---|---|
| Server-side broadcast in thin-relay | relay/cf/src/workspace-doc.ts (applyOpRequestThinRelay, seedBranchForOp, stableClientId) |
| Cold-load gate | ui/src/lib/realtime/connection-impl.ts (coldLoadFromSnapshot, coldLoadFromRest) |
| Whole-tree replacement for structural ops | lib/mutations/src/apply.ts (sections / nodes branches) |
| Field-level merge on existing maps | ui/src/lib/realtime/stores.svelte.ts (JobsStore.update) |
| D1 snapshot writer (leader tab) | ui/src/lib/realtime/snapshot-writer.ts (SnapshotWriter) |