Wave 3 — Opus CVL Sync / CRDT Vertical Slice
Agent: opus-cvl-crdt-sync
Date: 2026-04-25
Worktree: /home/richc/Documents/GitHub/careervector-wave3-opus-cvl-crdt-sync
Branch: wave3-opus-cvl-crdt-sync
Base: 1278fee (Agent wave 3 coordination)
Option Chosen
Option 1 — feature-gated vertical slice, narrowest safe form.
A full Yjs CRDT cutover (projection + DO persistence + coexistence with REST + migration) is multi-day work with cascading risk. A raw whole-document broadcast on the existing relay is exactly the silent-LWW anti-pattern the task warned against. What fits this wave's "narrow patch" constraint is a post-commit document broadcast gated behind safety rails that:
- leaves REST/D1 as the authoritative write path (unchanged),
- reuses the existing
settings_updatewire-type (no Durable Object redeploy), - is OFF by default so the known-bug test keeps failing as expected,
- applies on the receiver only if four purity gates pass (shape, lang, version, save-state),
- ships with pure-function unit tests that make the guards auditable,
- lands behind one opt-in flag (
?cvlSync=1/localStorage.cvl_sync=1/window.__cvlSync=true).
This is not the final CRDT architecture. It is the smallest meaningful prerequisite patch: it introduces the protocol extension, the receiver pipeline, the version gating, and the feature-flag plumbing that a Yjs slice will later replace with real CRDT merge. When the Yjs path from sonnet-crdt-projection lands, the plain-JSON broadcast disappears; the feature-flag stays as the switch.
Files Changed
| File | Change |
|---|---|
src/lib/cvl-sync.ts |
New. Feature gate (isCvlSyncEnabled), payload parser (parseCvDocPayload), pure apply-decision function (decideCvDocApply) with shape / wrong-lang / stale / saving drops. |
src/components/cvl/index.tsx |
Subscribe to useRealtime; parse incoming settings_update payloads for embedded cvDoc, apply via DocumentStackSchema.safeParse when safety gates pass. On successful apiPut in saveProfile / saveQuarryCl, broadcast { cvDoc: { bucket, lang, value, version } } if the feature gate is on. Local save state is tracked via refs so the apply handler reads the latest values. |
tests/unit/cvl-sync.test.ts |
New. 13 unit tests covering parseCvDocPayload and decideCvDocApply — shape rejections, lang gating, version staleness, in-flight-save drop, wire-format fallback, and function purity. |
src/components/cvl/index.tsx | 72 +++++++++++++++++++++++---
src/lib/cvl-sync.ts | (new, ~100 LOC)
tests/unit/cvl-sync.test.ts | (new, 13 tests)
No changes to: package.json, lockfiles, wrangler.toml, the realtime DO worker, D1 schema, REST handlers, the known-bug e2e spec, or any file owned by other agents.
Protocol Shape
The wire protocol is extended without bumping any version. settings_update's payload is already z.record(z.string(), z.unknown()) in realtimeConnection.ts, and existing consumers (Dashboard, Kanban) branch on payload.settings, payload.layout, payload.order_state. Adding an optional payload.cvDoc key is transparent to them.
// Broadcast (on sender side, after apiPut resolves)
{
type: 'settings_update',
payload: {
cvDoc: {
bucket: 'cv_profile' | 'cl_profile',
lang: 'en' | 'de' | ...,
value: <DocumentStack>,
version: <number> // D1 workspace version returned from the PUT
}
},
userId, timestamp,
}
The Durable Object (workers/realtime/src/index.ts) whitelists settings_update in its switch-case and calls broadcastExcept(...). No DO deploy required. Binary-relay path is untouched — this patch adds nothing to the Yjs binary channel.
Safety Analysis
Whole-document broadcasts can silently lose concurrent edits. The guards here close the known loss modes:
| Guard | What it prevents | Failure mode if removed |
|---|---|---|
| Shape | Malformed cvDoc (wrong keys, non-number version, non-string lang, missing value) |
Setting cvMap to garbage / throwing inside setState. |
| Lang | Incoming message for de is applied on a tab viewing en |
Replacing the inactive language's local data with a different language's document. |
Stale (incoming.version <= localVersion) |
Reconnect or out-of-order messages overwriting newer state | Downgrade; later PUT would 409 but between receipt and PUT the UI shows stale. |
Saving (saveState === 'saving') |
Overwriting unflushed local edits during a peer's burst | Losing the character the user just typed. |
DocumentStackSchema.safeParse |
Peer sends a valid-shape cvDoc with a malformed document |
Corrupt state that breaks the serializer. |
D1 remains the authoritative arbitrator: checkVersion in src/lib/api-handlers/put.ts still rejects stale PUTs with 409 regardless of what broadcasts did. A dropped broadcast is not lost data; the D1 row already has the new state. Tab B's next page load, or any explicit refetch, converges.
What this does NOT fix (by design)
- Concurrent character-level edits on the same text block. Two tabs typing into the same bullet still race. The last PUT wins at D1; earlier edits are lost the same way they are today. Yjs-level merge is the only fix.
- Long-running disconnection. If Tab B is offline while Tab A reorders 50 times, reconnection will fire several replays. The version gate keeps the last one; intermediate states never reach Tab B. This matches the broadcast-is-cache-invalidation model, not a full event log.
- The known-bug e2e test. Intentionally preserved: feature is OFF by default, so Tab B still requires a refresh, the soft assertion still fails,
test.fail(true)still pass-inverts the result. When a consumer flips the flag (or we decide to remove it), the test begins passing unexpectedly — that's the signal to retire the annotation.
Migration / Rollout Plan
- Merge this patch with the feature OFF. Zero production behavior change. The broadcast branch in
saveProfile/saveQuarryClis dead code at runtime. The receiver'sonSettingsUpdateshort-circuits onisCvlSyncEnabled() === false. - Internal testing. Flip
localStorage.cvl_sync = '1'in two tabs of the same workspace against a workspace that exists in the backing D1. Verify reorders, text edits, and variant toggles propagate. ConfirmsaveState === 'saving'drops are hit under a rapid-edit race. - Gated beta. Add a per-workspace
settings.cvlSyncflag (one-line change inWorkspaceSettings) and letisCvlSyncEnabled()also consult the workspace settings. Turn on for specific workspaces via the settings UI. - Deprecation path. When the Yjs vertical slice from
.agent/runs/sonnet-crdt-projection.mdlands with DO persistence (sonnet-do-persistence.md), thecvDocJSON broadcast is removed and the feature-flag gate is rerouted to enable the Yjs path on opt-in, then default-on.cvl-sync.tsbecomes dead code and can be deleted.
Rollback
Removing this patch is a revert. Runtime rollback while installed: clear localStorage.cvl_sync and reload. No persisted state, no D1 schema additions, no DO state.
Why piggyback on settings_update instead of a new doc_update type
The DO's webSocketMessage handler in workers/realtime/src/index.ts:109–135 uses an explicit switch on known message types; any unknown type is silently dropped. Adding doc_update would require a DO worker redeploy, which is out of scope for this patch (wrangler.toml and worker changes need steward approval per .agent/ownership.md). settings_update already accepts opaque payloads in its declared schema, so the extension is backward- and forward-compatible.
Validation
All commands run in the worktree:
npx astro sync && npx tsc --noEmit
Clean. Only pre-existing errors remain and they match the wave 2 baseline: 4 × "Cannot find module '@pulumi/…'" in infra/index.ts (Pulumi packages not installed locally, unrelated to any touched file).
npm run build
Passes. Client bundles emit, server bundle built in 6.4s. No new chunk-size warnings beyond vite's existing informational note about route bundles > 500 kB.
npm run test:unit
Test Files 51 passed (51)
Tests 905 passed (905)
Δ from wave 2 baseline: +13 tests (all from tests/unit/cvl-sync.test.ts). No regressions.
npm run test:e2e
4 passed (13.4s)
All CVL reorder / preview / doc-switch journeys still green.
npm run test:e2e:known-bugs
1 passed (9.7s)
The CVL two-tab sync known-bug test runs, the soft assertion "Tab B should see Tab A's reorder live without refreshing" still fails, test.fail(true) inverts the result to a pass. Feature gate verified off — as the annotation requires.
Risk Analysis
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| A third party flips the gate on in production without understanding the LWW residual risk | Low | Medium (data loss on concurrent edits) | Off by default; opt-in per-device only; documented in cvl-sync.ts module header. |
Local e2e cannot exercise the ON-mode end-to-end because the realtime worker at wss://careervector-realtime.corbet.workers.dev checks prod D1 |
Certain | Low | Guards are covered by unit tests; future e2e can either run the worker via wrangler dev or create a test workspace in prod D1. |
Adding a WS subscription from the CVL component increases connection count per workspace from 0 (if user goes direct to /cv) to 1 |
Certain | Negligible | Singleton connection manager deduplicates per workspace; Dashboard already holds it whenever both UIs are open. |
settings_update semantic drift — consumers start treating cvDoc as a general side-channel |
Low | Low | cvl-sync.ts owns the only writer and the only authorized reader; unit tests assert the parser rejects unknown bucket values. |
| Version-tracking uses the workspace-wide version counter (single counter, all buckets), so a Dashboard settings write increments it between a peer's broadcast send and receive | Possible | Low | The <= stale check lets the broadcast through if the peer's version is still older after a concurrent settings change; worst case a stale-at-broadcast-time update is applied, which is no worse than the status quo of Tab B being fully stale. |
| DocumentStack payloads are ~20 KB; a burst of reorders generates several messages | Possible | Low | Saves are debounced at 1s; one PUT = one broadcast. Not a raw keystroke firehose. |
Recommendation
Merge — OFF by default, treat as infrastructure for the Yjs vertical slice that follows.
The patch is small, isolated to the CVL editor, introduces one new module and one new test file, and changes zero existing behavior in its default state. It gives the steward three concrete artifacts to use in the next wave:
- A protocol extension that works without touching the DO.
- Pure, unit-tested safety-gate logic that any future realtime document — Yjs or otherwise — can reuse.
- The feature-flag plumbing and the opt-in convention.
Reasons to revise rather than merge: none identified.
Reasons to reject: if the steward's judgement is that any whole-document broadcast, even gated, is off the table, then this patch should hold for the Yjs slice to land first. In that case, the cvl-sync.ts pure functions and unit tests can still be kept as-is; only the broadcast and subscription wiring in index.tsx would be reverted.
Deliberate Non-actions
- Did not rename migration files or change
wrangler.toml(still MIG-1 from wave 2's scope). - Did not touch the DO worker code.
- Did not remove
test.fail(true)frome2e/known-bugs/cvl-sync.spec.ts— the gate is off, so two-tab sync still needs a refresh, and the annotation remains correct. - Did not import Yjs or any CRDT library. The prototype in
prototype/crdt/fromsonnet-crdt-projectionis untouched and still the canonical on-ramp for the next wave. - Did not write anything under
.claude,.chatgpt, or any hidden coordination folder in the repo.