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_update wire-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

  1. Merge this patch with the feature OFF. Zero production behavior change. The broadcast branch in saveProfile/saveQuarryCl is dead code at runtime. The receiver's onSettingsUpdate short-circuits on isCvlSyncEnabled() === false.
  2. 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. Confirm saveState === 'saving' drops are hit under a rapid-edit race.
  3. Gated beta. Add a per-workspace settings.cvlSync flag (one-line change in WorkspaceSettings) and let isCvlSyncEnabled() also consult the workspace settings. Turn on for specific workspaces via the settings UI.
  4. Deprecation path. When the Yjs vertical slice from .agent/runs/sonnet-crdt-projection.md lands with DO persistence (sonnet-do-persistence.md), the cvDoc JSON broadcast is removed and the feature-flag gate is rerouted to enable the Yjs path on opt-in, then default-on. cvl-sync.ts becomes 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:

  1. A protocol extension that works without touching the DO.
  2. Pure, unit-tested safety-gate logic that any future realtime document — Yjs or otherwise — can reuse.
  3. 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) from e2e/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/ from sonnet-crdt-projection is 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.
Source: wiki/content/archive/2026-05/agent-runs/opus-cvl-crdt-sync.md