Skip to content

Realtime sync: replace whole-document LWW note body with a Yjs CRDT (y-codemirror.next + Postgres update log), collab-ready #59

Description

@chasehuh

Summary

Replace AgentNote's whole-document last-write-wins note body sync with a Yjs CRDT document model bound to CodeMirror 6 via y-codemirror.next, persisted in the existing Postgres as an append-only update log plus periodic snapshots, and synced over plain HTTP route handlers first (no new infrastructure), with a WebSocket transport as a drop-in Phase 2.

notes.body stays in the schema as a server-derived plaintext projection of the CRDT, so publish//p/..., note_revisions, title derivation, archive, and the sidebar preview keep working with zero changes.

Goal: make silent body truncation structurally impossible rather than defended against by an ever-growing set of guards (#51, #53, #57), and put true concurrent editing (same user across tabs/devices now, multiple users later) on the table without a rewrite.

Why This Matters

Body-loss incidents keep recurring on this repo despite three rounds of hardening:

Every one of those fixes is correct, and the current code on main is genuinely careful. But they all share one root cause: the unit of synchronization is the entire note body string. With a whole-document write, "who wins" is a policy decision, and every policy has a losing side. lib/remote-apply-guard.ts now holds six separate predicates (canApplyRemoteBody, isRemoteNoteNewer, shouldMarkSavedAfterPersist, canAdvanceBaseWithoutAdopting, isDraftBaseCurrent, shouldAcceptDraftSeq) whose only job is to decide which whole body to discard. Under OCC the loss mode changed shape but did not disappear — it became "409 → user must choose", i.e. one side's typing is still thrown away.

A CRDT changes the unit of synchronization to operations on a shared text. Concurrent inserts/deletes from two tabs both survive and merge deterministically. There is no "stale writer", no generation token to get wrong, and no discard prompt. This is the same model Obsidian Sync, Linear, and Figma converged on, and the reason Yjs exists.

Secondary benefits that matter for this specific product:

  • Multi-device (laptop ↔ phone ↔ iPad) becomes correct instead of best-effort.
  • Offline editing becomes possible (y-indexeddb) — today an offline edit is a pending PUT that will 409.
  • The agentnote premise ("sits next to the agent tab") implies the same note is open in several places at once. That is exactly the workload LWW is worst at.
  • Multi-user collaboration later is an ACL change, not a rewrite.

Conversation Context

Condensed from the design conversation that produced this issue:

  • The operator is the primary (currently only) user. Real workload: one human, many tabs and devices, not many humans. Multi-user is a future requirement — the design must not preclude it, but v1 must not build a Figma-style multiplayer UI.
  • Prior research/drafts on this topic were lost twice. This issue is deliberately written to be self-contained: an implementing agent should never need the chat.
  • Explicit ask: research best-practice code (real repos, real packages, real API shapes), not blog-level hand-waving, and compare (A) stronger OCC, (B) field-level / 3-way text merge, (C) CRDT — then pick one.
  • Explicit ask: phased rollout. Phase 1 must stop the data loss on its own and be shippable without new infrastructure.
  • Hard constraint from the operator: do not boil the ocean. No presence avatars, no comments, no multiplayer UX in v1.
  • Deployment reality: Next.js App Router 16 on Vercel, Postgres (pg) hosted on Railway, Clerk for auth (agentnote Clerk app, GitHub OAuth). Any transport choice must respect that Vercel functions are not a durable WebSocket hub (see Source Of Truth).
  • The existing OCC work from Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 is not being thrown away — it stays as the concurrency control for note metadata (title fallback, publish, archive, restore) and as the legacy body path behind a flag.

Current Behavior

All paths below are on origin/main as of commit 247aa89 ("fix: optimistic concurrency + buffer guards to stop stale-tab truncation (#57) (#58)"). A local checkout may be dozens of commits behind — read from origin/main.

Data model (lib/db.tsensureSchema())

CREATE TABLE IF NOT EXISTS notes (
  id           TEXT PRIMARY KEY,               -- Meet-style "abc-defg-hij"
  user_id      TEXT NOT NULL,                  -- Clerk user id
  title        TEXT NOT NULL DEFAULT '',
  body         TEXT NOT NULL DEFAULT '',       -- <-- the whole-document field
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  deleted_at   TIMESTAMPTZ,                    -- soft delete (Archived)
  is_public    BOOLEAN NOT NULL DEFAULT FALSE,
  public_id    TEXT,
  published_at TIMESTAMPTZ,
  author_handle TEXT
);

CREATE TABLE IF NOT EXISTS note_aliases (
  alias   TEXT PRIMARY KEY,
  note_id TEXT NOT NULL REFERENCES notes(id) ON UPDATE CASCADE ON DELETE CASCADE
);

-- Recovery net from #52, 30-day retention, 60s coalesce window
CREATE TABLE IF NOT EXISTS note_revisions (
  id         BIGSERIAL PRIMARY KEY,
  note_id    TEXT NOT NULL REFERENCES notes(id) ON UPDATE CASCADE ON DELETE CASCADE,
  user_id    TEXT NOT NULL,
  title      TEXT NOT NULL,
  body       TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

notes.updated_at doubles as the optimistic-concurrency token.

Write path — PUT /api/notes/[id] (app/api/notes/[id]/route.tslib/notes.ts::updateNote)

Request body:

{ "title": "string", "body": "string", "expected_updated_at": "2026-07-30T12:00:00.000Z" }
  • expected_updated_at is required; a missing token is a hard 400 ("fail closed: old clients without a concurrency token must not LWW-write").
  • The guard compares millisecond epochs, using FLOOR(EXTRACT(EPOCH FROM updated_at) * 1000)::bigintFLOOR, not a rounding ::bigint cast, because clients send ms truncated by Date#toISOString() and half-up rounding would false-409 roughly half of all notes.
  • The write is a single CTE (WITH prev AS (SELECT ...), updated AS (UPDATE ... FROM prev WHERE ... AND FLOOR(...)= $5)) so a concurrent save cannot interleave between the read of the previous body and the overwrite. The FLOOR gate is repeated on the UPDATE row because under READ COMMITTED the EvalPlanQual recheck would otherwise let two same-token writers both win after the CTE snapshot.
  • On token mismatch → 409 { "error": "Conflict", "note": <server note> }.
  • On success, the pre-update body is written to note_revisions (best-effort, never fails the save, coalesced to one row per 60s).

This is still a whole-document overwrite. OCC narrows the window; it does not merge.

Client state machine (components/agentnote-app.tsx, ~1384 lines)

Constants:

Name Value Meaning
POLL_MS 1500 pullFromServer() interval while document.visibilityState === "visible"
DRAFT_BROADCAST_MS 32 debounce before broadcasting an unsaved draft to peer tabs
autosave debounce 400 (inline setTimeout) delay from last keystroke to PUT
SAVE_RETRY_BACKOFF_MS [1000, 2000, 4000, 8000] lib/save-failure.ts

Refs that constitute the concurrency protocol:

  • bodyRefState — live editor buffer, written in the same turn as setBody (setBodyNow) so poll/BroadcastChannel callbacks never see a one-frame-stale value (regression from Chrome: editor viewport/caret jumps to top — full-document CM6 replacement collapses scroll anchor and selection to offset 0 #47 T4).
  • lastAckedBodyRef — body from the last successful server ack or clean open.
  • baseUpdatedAtRefthe generation the buffer was built on, deliberately not the newest list row. persist() sends this as expected_updated_at. It advances only when the buffer adopts server state.
  • saveSeqRef / isCurrentSaveAttempt() — drops stale persist results so an older body can never land after a newer save.
  • persistInFlightRefflushPendingSave() awaits it, because a second same-token PUT races into a self-409.
  • draftSeqRef + lastDraftSeqByPeer — per-peer monotonic draft sequence for out-of-order ignore.

Save states: "saved" | "saving" | "dirty" | "error"; hasUnsavedWork() treats dirty/saving/error alike.

Cross-tab sync (lib/tab-sync.ts)

BroadcastChannel("agentnote.sync." + userId) — per-user channel name, same browser/origin only. Message union:

| { type: "draft";   sourceId: string; id: string; body: string; title: string;
    at: number; baseUpdatedAt?: string; draftSeq?: number }
| { type: "upsert";  sourceId: string; note: Note }
| { type: "delete";  sourceId: string; id: string }
| { type: "archive"; sourceId: string; note: Note }
| { type: "restore"; sourceId: string; note: Note }

A draft carries the entire body string. Receivers gate it through isDraftBaseCurrent() (missing base fails closed) and shouldAcceptDraftSeq().

Apply guards (lib/remote-apply-guard.ts)

Predicate Rule
canApplyRemoteBody local unsaved work always wins — dirty/saving/error all refuse, and forceBody is explicitly ignored (void opts?.forceBody). Additionally refuses when localBody !== lastAckedBody even in saved state.
isRemoteNoteNewer strict > on updated_at — equal timestamps keep local, so client-clock drafts cannot win a tie.
shouldMarkSavedAfterPersist only mark saved if the live buffer still equals what was persisted.
canAdvanceBaseWithoutAdopting a refused remote body must not advance baseUpdatedAtRef — otherwise a stale buffer gets a valid ticket (the exact #57 bug). Only body-neutral bumps (publish/unpublish/restore) may advance it.
isDraftBaseCurrent peer draft applies only when generations match; undefined base fails closed.
shouldAcceptDraftSeq ignore out-of-order peer drafts.
noteAfterConflictKeepLocalBuffer on 409, rebase the token but keep the local buffer so autosave does not auto-PUT the stale short body.

shouldAutoRetrySave() deliberately returns false for conflict: "Conflict must not auto-retry — rebasing + PUT would recreate LWW loss." The user is left in error state with a manual Retry.

Editor (components/codemirror-editor.tsx + lib/editor/apply-external.ts)

  • CM6 with @codemirror/lang-markdown, custom Enter list continuation, Tab list indent, strikethrough, arrow substitution, inline image widgets, indentedLineWrapping(), scrollPastEnd().
  • The editor is controlled by a value: string prop. Parent remounts it with key={noteId}.
  • External values go through applyExternalValue(view, next), a Zed Buffer::apply_diff analogue:
    • minimalChange() computes one common-prefix/suffix-trimmed replace range, snapping off UTF-16 surrogate-pair interiors;
    • returns false (skip) when the local selection intersects the remote hunk (sel.from <= to && sel.to >= from);
    • returns false while view.composing (IME);
    • dispatches with scrollIntoView: false and no explicit selection so ChangeSet mapping preserves caret + scroll.
  • On skip, onExternalReconcile syncs React from CM without marking dirty (reconcileBodyFromEditor sets skipNextSave.current = true).

Tests on main

lib/remote-apply-guard.test.ts, lib/save-failure.test.ts, lib/notes-revisions.test.ts, lib/editor/apply-external.test.ts, lib/editor/apply-external.dom.test.ts, lib/editor/arrow-input.test.ts, lib/editor/image-widgets.test.ts. Runner: vitest (pnpm test), jsdom available.

What is explicitly NOT collaborative today

  1. No merge, anywhere. Every sync path picks one whole body and discards the other.
  2. No operation history on the wire. draft and upsert carry final strings, so intent (insert "x" at offset 40) is unrecoverable.
  3. No server push. 1.5s poll only, and only while the tab is visible. A background tab is arbitrarily stale.
  4. No offline story. An offline edit becomes a failed PUT; on reconnect its token is stale → 409 → manual choice.
  5. No presence. No cursors, no "editing elsewhere" indicator.
  6. Conflicts are user-visible losses. saveErrorKind === "conflict" requires a human to pick a side.
  7. Cross-device is strictly worse than cross-tab. No BroadcastChannel between devices — only the 1.5s poll, and applyRemoteNote refuses to adopt whenever the local tab is dirty, so two devices editing the same note diverge until one gives up its work.

Desired Behavior

After this issue:

  1. A note's body is a Yjs Y.Text. Concurrent edits from any number of tabs/devices converge to a single deterministic document. No edit is ever discarded to resolve a conflict.
  2. Typing in tab A appears in tab B within ~1 frame (BroadcastChannel) and on another device within one sync round-trip (~1.5s in Phase 1, <100ms in Phase 2).
  3. Editing the same note on two devices simultaneously merges character-level. There is no 409, no discard prompt, and no "unsaved changes" modal for the body path.
  4. notes.body remains a correct plaintext mirror of the CRDT at all times (eventually consistent within one sync round), so /p/{handle}/{id}, note_revisions, sidebar previews, and derived titles are unchanged.
  5. Reloading mid-edit loses nothing: local state is in IndexedDB, and unsynced updates flush on reconnect.
  6. The editor stops being a controlled value: string component for CRDT-backed notes; CM6 is driven by yCollab, which preserves caret/scroll/IME by construction — making applyExternalValue's heuristics unnecessary on that path.
  7. Rollout is gated by a flag so the legacy OCC path remains available for one release.

Source Of Truth

Internal repo/source — read these first

Path Why it matters
components/agentnote-app.tsx persist(), pullFromServer(), applyRemoteNote(), applyRemoteDraft(), broadcastDraft(), flushPendingSave(), ensureSafeToLeaveActive(), POLL_MS, autosave 400. The whole body sync state machine lives here.
components/codemirror-editor.tsx CM6 construction, editorExtensions(), the valueapplyExternalValue effect, applyingExternal guard, key={noteId} remount contract.
lib/editor/apply-external.ts minimalChange() + selection/IME skip semantics being replaced on the CRDT path.
lib/remote-apply-guard.ts The six discard predicates. Their tests encode the #51/#57 invariants — do not delete them while the legacy path exists.
lib/save-failure.ts SAVE_RETRY_BACKOFF_MS, classifySaveHttpStatus, shouldAutoRetrySave (conflict must not auto-retry), isCurrentSaveAttempt.
lib/tab-sync.ts openSyncChannel(), syncChannelName(userId), the SyncMessage union to extend.
lib/notes.ts updateNote() CTE with the FLOOR(EXTRACT(EPOCH ...)) OCC gate; recordPreviousBodyRevision(); resolveCanonicalNoteId() (aliases!).
lib/db.ts ensureSchema() — idempotent, runs at query time behind global.__agentnoteSchemaReady. New tables go here, same style.
app/api/notes/[id]/route.ts PUT handler, expected_updated_at 400/409 contract.
app/api/notes/route.ts GET list / POST create.
lib/require-user.ts requireUserId() → `{ userId }
proxy.ts clerkMiddleware; public matcher list (/login(.*), /p(.*), /api/version, /api/cron(.*)). New /api/notes/** routes are protected by default.
lib/types.ts Note / PublicNote shapes returned to the client.
vercel.json Existing cron entries — the compaction cron goes here.

Prior issues (context, not duplicates)

External docs/source

Yjs core

  • Yjs — https://github.com/yjs/yjs — npm yjs@13.6.31 (verified 2026-07-31).
  • Document updates API — https://docs.yjs.dev/api/document-updates
    • Y.applyUpdate(doc, update: Uint8Array, origin?: any)
    • Y.encodeStateAsUpdate(doc, encodedTargetStateVector?: Uint8Array): Uint8Array
    • Y.encodeStateVector(doc): Uint8Array
    • Y.encodeStateVectorFromUpdate(update: Uint8Array): Uint8Array — state vector without loading a Y.Doc
    • Y.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array — diff on binary, no Y.Doc
    • Y.mergeUpdates(updates: Uint8Array[]): Uint8Array — compaction; dedupes, improves compression
    • doc.on('update', (update: Uint8Array, origin: any, doc: Y.Doc) => void)
    • Key property: updates are commutative, associative, and idempotent — apply in any order, any number of times. This is the entire reason the truncation class of bug disappears.
    • Caveat from the docs: mergeUpdates does not garbage-collect deleted content; only a full Y.Doc load + re-encode shrinks a document meaningfully.
  • Sync protocol — https://github.com/yjs/y-protocols/blob/master/PROTOCOL.md — npm y-protocols@1.0.7
    • SyncStep1 (id 0) = sender's state vector; SyncStep2 (id 1) = the missing updates; Update (id 2) = an incremental update.
    • Awareness: per-client (state, clock, timestamp); an entry not refreshed for 30s MUST be removed locally.
    • Reference dispatch uses decoding.readVarUint()sync.readSyncMessage() / awareness.applyAwarenessUpdate(), with lib0/encoding + lib0/decoding primitives (lib0@0.2.117).
    • This maps 1:1 onto request/response: a POST carrying { sv, update } and returning { update } is SyncStep1+Update in, SyncStep2 out.

CodeMirror 6 binding — critical, this app is CM6

  • y-codemirror.nexthttps://github.com/yjs/y-codemirror.next — npm y-codemirror.next@0.3.5 (verified 2026-07-31).
    • Exports: yCollab, ySync, ySyncFacet, YSyncConfig, yRemoteSelections, yUndoManagerKeymap, yCollabConfig.
    • Canonical usage:
      import * as Y from 'yjs'
      import { yCollab } from 'y-codemirror.next'
      import { EditorState } from '@codemirror/state'
      
      const ydoc = new Y.Doc()
      const ytext = ydoc.getText('body')
      const undoManager = new Y.UndoManager(ytext)
      
      const state = EditorState.create({
        doc: ytext.toString(),
        extensions: [ /* existing agentnote extensions */,
          yCollab(ytext, awareness, { undoManager })
        ]
      })
    • Version note: the repo's main branch is the unstable @y/codemirror line targeting Yjs v14 (@y/y). Stay on the published y-codemirror.next + yjs@13.x for this issue. The README example on GitHub shows import * as Y from '@y/y' — that is the v14 branch; use from 'yjs'.
    • yCollab installs its own undo/redo. It must replace, not stack on, @codemirror/commands' history()/historyKeymap for CRDT-backed notes, or ⌘Z will fight itself. See Edge Cases.

Persistence reference implementations (read these, they solve exactly this problem)

  • y-postgresqlhttps://github.com/MaxNoetzold/y-postgresql — npm y-postgresql. API: PostgresqlPersistence.build(connectionOptions, persistenceOptions), then getYDoc(docName), storeUpdate(docName, update), getStateVector(docName), getDiff(docName, stateVector), clearDocument(docName), destroy(). Stores individual updates and merges them into one document after a threshold — default 200 transactions. Implements the y-websocket persistence interface { bindState, writeState }: bindState subscribes to doc.on('update') and stores incrementally; writeState persists when the last client disconnects. This is the closest existing analogue to what this issue builds — use it as a design reference even if not adopted as a dependency.
  • y-indexeddbhttps://github.com/yjs/y-indexeddb — npm y-indexeddb@9.0.12. Offline/local persistence; replicates state to every peer that has visited the document, so if the server ever loses data a client can sync it back. Combine with a network provider (docs: https://docs.yjs.dev/getting-started/allowing-offline-editing).
  • Hocuspocus Database extension — https://tiptap.dev/docs/hocuspocus/server/extensions/database — npm @hocuspocus/extension-database@4.4.0. fetch({ documentName }) => Promise<Uint8Array | null> and store({ documentName, state }) => Promise<void>. Documented pitfall, applies to us too: "Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, as doing so would lead to a new history (duplicated content)."

Transport options evaluated

  • Vercel native WebSocket — https://vercel.com/changelog/websocket-support-is-now-in-public-betapublic beta since 2026-06-22, runs on Fluid compute with Node (ws / Socket.IO work; standard upgrade triggers a function invocation that stays pinned to the connection). Billing is Active-CPU, so idle connections are cheap. But: connections are pinned to one instance for its max duration, future connections are not guaranteed to land on the same instance, and there is no built-in cross-instance broadcast. A Yjs room needs all peers of a document on one authority — so Vercel WS alone is not a sufficient Yjs hub today. Vercel's own KB still points collaborative apps at Ably / Liveblocks / PartyKit / Pusher / Supabase. (Needs verification before Phase 2: whether the beta has since gained a documented affinity/broadcast primitive.)
  • Hocuspocus — https://github.com/ueberdosis/hocuspocus — npm @hocuspocus/server@4.4.0. Long-lived Node WebSocket server for Yjs. Auth via the onAuthenticate({ documentName, token, requestHeaders, ... }) hook: validate a token (e.g. a Clerk JWT), throw to reject, return a context object consumed by later hooks (beforeHandleMessage, onStoreDocument); connection.readOnly supports read-only grants. Deployable next to the existing Railway Postgres.
  • y-partyserverhttps://github.com/cloudflare/partykit/tree/main/packages/y-partyserver — npm y-partyserver@2.2.0. Yjs on Cloudflare Durable Objects; YServer is withYjs(Server). A Durable Object gives exactly the single-authority-per-document property a Yjs room needs.
  • y-durableobjectshttps://github.com/napolab/y-durableobjects — alternative DO implementation, no Node dependency.
  • y-sweethttps://github.com/jamsocket/y-sweet — Yjs document store with S3-compatible persistence + auth; self-host or Jamsocket. (Note: issue #203 in that repo reports high Cloudflare Worker runtime cost for the Worker deployment — evaluate before adopting.)
  • Liveblocks / TipTap Collab — fully managed Yjs backends with CodeMirror guides. Fastest path, but adds a vendor and a per-seat cost for what is currently a one-user app.
  • y-websocket@3.0.0https://github.com/yjs/y-websocket — the reference provider/server; fine for local dev and for a Railway-hosted node.

Algorithm background for the A/B/C comparison

  • OT vs CRDT: OT needs a central, reliable server and keeps documents lean; CRDTs carry per-character metadata (a naive text CRDT adds ~16–32 bytes/char) but work offline and peer-to-peer. Google Docs uses OT; Figma moved to CRDTs in 2019; Obsidian/Linear/Yjs-based apps are CRDT. Yjs specifically uses YATA with an optimized run-length item representation, so real-world markdown notes do not hit the naive 32×-blowup figure — but the update log does grow monotonically, which is why compaction is mandatory (see Implementation Notes).
  • Automerge / automerge-repohttps://github.com/automerge/automerge — mature JSON CRDT with a sync-server story; strong for structured docs, but there is no first-party CodeMirror 6 binding as mature as y-codemirror.next, and it brings a WASM payload.
  • Loro — https://github.com/loro-dev/loro — Rust/WASM CRDT with excellent memory + load performance and a rich text type. Promising, but the CM6 ecosystem, provider ecosystem, and production track record are thinner than Yjs's. (Needs verification: current state of an official Loro CodeMirror 6 binding.)

Proposed API / Schema

DB migration (append to ensureSchema() in lib/db.ts, same idempotent style)

-- Append-only CRDT update log. Never UPDATEd, only INSERTed and (post-compaction) DELETEd.
CREATE TABLE IF NOT EXISTS note_doc_updates (
  seq        BIGSERIAL PRIMARY KEY,
  note_id    TEXT NOT NULL REFERENCES notes(id) ON UPDATE CASCADE ON DELETE CASCADE,
  user_id    TEXT NOT NULL,
  update_bin BYTEA NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS note_doc_updates_note_seq_idx
  ON note_doc_updates (note_id, seq);

-- Compacted state. One row per CRDT-backed note.
CREATE TABLE IF NOT EXISTS note_doc_snapshots (
  note_id      TEXT PRIMARY KEY REFERENCES notes(id) ON UPDATE CASCADE ON DELETE CASCADE,
  user_id      TEXT NOT NULL,
  state_bin    BYTEA NOT NULL,      -- Y.encodeStateAsUpdate(ydoc)
  state_vector BYTEA NOT NULL,      -- Y.encodeStateVector(ydoc)
  through_seq  BIGINT NOT NULL DEFAULT 0,  -- highest note_doc_updates.seq folded in
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Notes on the shape:

  • ON DELETE CASCADE mirrors note_revisions, so permanent delete and archive purge clean up automatically.
  • update_bin / state_bin avoid the reserved-ish word update as a column name.
  • through_seq is what makes "snapshot + tail" reads cheap and compaction safe.
  • No new user-facing columns on notes. notes.body keeps its meaning; it just gains a new writer.

GET /api/notes/[id]/doc

Returns the full document state, used on first open when the client has no local state.

Response 200:

{
  "note_id": "abc-defg-hij",
  "update": "AQHT4a...base64 Uint8Array...",
  "seq": 4217,
  "created": false
}
  • update = base64 of Y.encodeStateAsUpdate(ydoc) where ydoc = snapshot + all updates with seq > through_seq.
  • seq = highest note_doc_updates.seq folded into update (or through_seq when the tail is empty). The client passes it back as a since cursor.
  • created: true when this call performed first-time seeding from notes.body (see Flow step 2).
  • 404 when resolveCanonicalNoteId(userId, id) returns null — same not-found semantics and no existence leak as the current routes.

POST /api/notes/[id]/doc/sync — the workhorse (one round trip = push + pull)

Request:

{
  "update": "AQHT4a...base64, optional — omit for a pull-only poll",
  "state_vector": "AQEB...base64, optional — omit to receive nothing back",
  "since": 4217
}

Response 200:

{
  "seq": 4231,
  "update": "AQKz...base64 of the diff the client is missing, or null",
  "body": "# Note title\n\nprojected plaintext…",
  "updated_at": "2026-07-31T09:14:02.511Z"
}

Semantics:

  1. If update is present → append one row to note_doc_updates, then recompute the projection (see Flow).
  2. If state_vector is present → reply with Y.encodeStateAsUpdate(serverDoc, sv); null when the client is already current. since is an optimization: when since === current head seq and no local update was sent, the server may skip loading the doc entirely and return { seq, update: null }.
  3. body + updated_at are returned so the client can refresh the sidebar row without a second GET /api/notes.

This single endpoint is deliberately syncStep1 + update in, syncStep2 out — the y-protocols shape, over HTTP.

GET /api/notes/[id]/doc/stream (Phase 1.5, optional)

text/event-stream SSE emitting data: {"seq":4231,"update":"<base64>"} per new update. Cuts perceived cross-device latency without a WebSocket. Needs verification: Vercel Fluid compute max function duration for a long-lived SSE response and whether Postgres LISTEN/NOTIFY through the existing pg Pool is workable there, or whether a short-lived (~50s) SSE with client reconnect is the pragmatic shape.

PUT /api/notes/[id] — unchanged wire contract, narrowed meaning

  • Still requires expected_updated_at; still returns 409 { error, note }.
  • New rule: when a note_doc_snapshots row exists for the note (i.e. it is CRDT-backed) and the request carries a body, respond 409 with a machine-readable reason so a stale legacy client cannot LWW over the CRDT:
{ "error": "Conflict", "reason": "crdt_managed_body", "note": { "...": "..." } }
  • Title-only / publish / archive / restore paths are untouched.

Extended BroadcastChannel message (lib/tab-sync.ts)

| {
    /** Binary Yjs update for a note, mirrored to peer tabs instantly. */
    type: "doc-update";
    sourceId: string;
    id: string;
    update: Uint8Array;   // structured-clone handles this natively
  }

The existing draft message stays for the legacy path and is not sent for CRDT-backed notes.

Validation rules

  • update / state_vector: base64; reject payloads over 1 MiB decoded with 413. A single keystroke update is tens of bytes; anything larger is a bug or an attack.
  • Every route: requireUserId() first, then resolveCanonicalNoteId(userId, id) — never trust the path id. Aliases must resolve, matching getNote.
  • note_doc_updates.user_id is set server-side from Clerk, never from the payload.
  • A malformed update (Y.applyUpdate throws) → 400 { "error": "Invalid update" }, and the row is not appended. Never persist an update the server could not apply.
  • Backward compatibility: clients without the flag keep using PUT with expected_updated_at and are unaffected until their notes are seeded.

Implementation Notes

Recommended architecture — and why, versus the alternatives

Chosen: (C) Yjs CRDT + y-codemirror.next + Postgres update log/snapshot, HTTP-first transport.

Option What it fixes Why it is not enough
(A) Stay whole-document LWW, strengthen OCC / add a generation counter Replaces timestamp tokens with a monotonic generation BIGINT, removing the FLOOR(EXTRACT(EPOCH ...)) ms-truncation hazard. Cheap. Does not merge. Every conflict is still someone's typing thrown away. #57 already proved that adding guards converts one loss mode into another. Two devices editing one note remain mutually exclusive. This is polish on the wrong abstraction.
(B) Field-level / 3-way text merge (diff3, diff-match-patch) server-side Genuinely merges non-overlapping edits, no client rewrite, no new deps on the editor. 3-way merge needs a common ancestor the server does not keep (only the previous body, coalesced at 60s). Merge results are heuristic — silent mis-merges in markdown lists/tables are worse than a visible conflict. Overlapping edits still need a policy. And it produces no operation stream, so realtime and offline stay out of reach. It is strictly a worse CRDT.
(C) Yjs CRDT Concurrent edits merge by construction; conflicts stop existing as a category; realtime, offline, presence, and multi-user all become incremental additions on the same substrate; yCollab preserves caret/scroll/IME natively, retiring the applyExternalValue heuristics on that path. Real cost: a binary document model beside the plaintext one, monotonic log growth needing compaction, yjs on both client and server, and undo/redo must move to Y.UndoManager. All bounded and addressed below.

The decisive point for this product: AgentNote already pays the complexity cost of a CRDT (six discard predicates, three generation refs, a per-peer sequence map, a conflict UX) while getting none of the benefit. Switching substrate deletes more code than it adds.

Two choices keep the blast radius small:

  1. notes.body becomes a derived projection, not a second source of truth. Publish, revisions, previews, titles, archive, and the public route keep reading the exact column they read today. Nothing downstream of the editor changes.
  2. Phase 1 ships over the existing HTTP + poll transport. No WebSocket server, no vendor, no new deploy target. The correctness win (no truncation) lands first and independently; latency is a separate, later, purely-transport change.

Phased rollout

Phase 1 — Stop the data loss (the MVP; ship this alone if nothing else lands).
Yjs doc model, y-codemirror.next binding, note_doc_updates + note_doc_snapshots, GET /doc + POST /doc/sync, poll reuses the existing 1.5s cadence, BroadcastChannel carries binary updates, notes.body projection, compaction cron. Behind NEXT_PUBLIC_AGENTNOTE_CRDT. Outcome: truncation is structurally impossible; two tabs and two devices both merge.

Phase 1.5 — Local-first. y-indexeddb per note. Offline edits queue and flush on reconnect. Reload mid-edit loses nothing.

Phase 2 — True realtime transport. Swap the poll for a WebSocket provider. Recommended primary: Hocuspocus on Railway — a long-lived Node process beside the Postgres this project already runs there, with onAuthenticate verifying a Clerk JWT and @hocuspocus/extension-database fetch/store pointed at the same tables. Alternative if a Cloudflare-shaped deploy is preferred: y-partyserver on Durable Objects. Only the provider changes — the document model, schema, and editor binding are already correct. Note Vercel's native WS beta cannot host the room today (no cross-instance broadcast).

Phase 3 — Optional multi-user. Add a note_members(note_id, user_id, role) table, relax the room ACL from notes.user_id = auth.userId to membership, and enable yCollab's awareness (remote cursors, yRemoteSelections) which is already wired in Phase 1 with a single local client. No rewrite: the room key is already note_id and isolation is already enforced per request.

Likely files to modify

  • package.json — add yjs@^13.6.31, y-codemirror.next@^0.3.5, y-protocols@^1.0.7, lib0@^0.2.117; Phase 1.5 adds y-indexeddb@^9.0.12. Pin yjs to a single version — two copies of Yjs in one bundle silently break sync; add a pnpm.overrides entry if the lockfile ever dedupes badly.
  • lib/db.ts — the two CREATE TABLE IF NOT EXISTS blocks above, inside the existing ensureSchema() IIFE, before migrateLegacyNoteIds(pool).
  • components/codemirror-editor.tsx — accept an optional ytext?: Y.Text + awareness?: Awareness. When present: seed EditorState.create({ doc: ytext.toString() }), append yCollab(ytext, awareness, { undoManager }), and drop history() / historyKeymap from the extension list (see Edge Cases). When absent: today's value/applyExternalValue path, byte-for-byte unchanged.
  • components/agentnote-app.tsx — the largest change. For CRDT-backed notes, retire persist()/broadcastDraft()/applyRemoteDraft()/applyRemoteNote()-for-body in favour of a doc-session hook. Keep all of it for the legacy path. saveState becomes a much simpler "synced" | "syncing" | "offline" on the CRDT path — no dirty, no error-with-conflict, no beforeunload prompt for the body.
  • lib/tab-sync.ts — add the doc-update variant to SyncMessage.
  • lib/notes.ts — add getNoteDocState, appendNoteDocUpdate, compactNoteDoc, projectNoteBodyFromDoc; add the crdt_managed_body guard to updateNote.
  • app/api/notes/[id]/route.ts — return the crdt_managed_body 409 when a snapshot row exists and a body was sent.
  • vercel.json — add { "path": "/api/cron/compact-note-docs", "schedule": "30 9 * * *" } alongside the two existing 09:00-ish crons.
  • README.md — document the flag, the new tables, and the recovery story.
  • .env.exampleNEXT_PUBLIC_AGENTNOTE_CRDT=0.

New files

  • lib/crdt/note-doc.ts — pure, testable helpers: NOTE_TEXT_KEY = "body", seedDocFromPlaintext(body): Uint8Array, mergeUpdatesToState(updates): { state, stateVector }, docBodyFromState(state): string, shouldCompact({ updateCount, byteSize }). No pg, no fetch here — this is the unit-test surface.
  • lib/crdt/note-doc-store.ts — server-side Postgres I/O: load snapshot + tail, apply, append, compact, write projection. Uses query() from lib/db.ts.
  • lib/crdt/use-note-doc.ts — client hook owning one Y.Doc per active note: creates the doc, GET /doc on open, subscribes doc.on('update') → POST + BroadcastChannel, poll loop, applies inbound updates, exposes { ytext, awareness, syncState }. Must tag origins (Y.applyUpdate(doc, u, "remote")) so remote updates are not echoed back.
  • lib/crdt/sync-transport.ts — the HTTP transport behind a small interface (pull, push, subscribe?) so Phase 2 swaps in a WebSocket provider without touching the hook.
  • app/api/notes/[id]/doc/route.tsGET full state.
  • app/api/notes/[id]/doc/sync/route.tsPOST push+pull.
  • app/api/cron/compact-note-docs/route.ts — mirror app/api/cron/purge-note-revisions/route.ts exactly, including the Bearer CRON_SECRET check (that path is public in proxy.ts, so the in-route check is the only gate).
  • Tests: lib/crdt/note-doc.test.ts, lib/crdt/note-doc-store.test.ts, lib/crdt/convergence.test.ts.

Flow

  1. Open a note. use-note-doc creates new Y.Doc(), calls GET /api/notes/:id/doc, Y.applyUpdate(doc, base64ToBytes(update), "remote"), records seq, hands ytext to CM6 via yCollab.
  2. First-time seeding (server, exactly once per note). If no note_doc_snapshots row exists: build a doc, ydoc.getText("body").insert(0, note.body), encode, and INSERT ... ON CONFLICT (note_id) DO NOTHING, then re-read the row and return the winner's state. Do the insert only on the server. Seeding on the client — or on two servers without the ON CONFLICT guard — is the single most common Yjs bug and produces duplicated document content (the Hocuspocus docs call this out explicitly).
  3. Local edit. CM6 dispatch → yCollab mutates ytextdoc.on('update') fires with origin === null (local). The handler (a) posts { type: "doc-update", update } on the BroadcastChannel immediately — peer tabs converge in ~1 frame — and (b) enqueues the update for the network. Debounce network pushes with the existing 400 ms autosave feel, merging queued updates with Y.mergeUpdates() into one POST.
  4. Push. POST /api/notes/:id/doc/sync with { update, state_vector, since }. Server: requireUserId()resolveCanonicalNoteId()pg_advisory_xact_lock(hashtext($note_id)) → validate by applying to the loaded doc → INSERT INTO note_doc_updates → recompute body = ydoc.getText("body").toString()UPDATE notes SET body = $1, title = <deriveTitle(body)>, updated_at = NOW() → reply with the diff the client is missing plus the fresh body/updated_at.
    • The advisory lock serializes the projection write only. The log append itself is commutative and needs no lock for correctness; the lock exists so two concurrent serverless invocations cannot write projections computed from different tail sets and leave notes.body behind the log.
    • Keep recordPreviousBodyRevision() on the projection write (still coalesced to 60 s) — note_revisions stays useful as a human-readable time machine even though CRDT recovery no longer depends on it.
  5. Pull. The existing 1.5 s visible-tab poll calls the same endpoint with no update. Remote updates are applied with origin "remote", which yCollab renders without disturbing the local selection or scroll — no applyExternalValue heuristics, no IME special-case.
  6. Peer tab receives doc-update. Y.applyUpdate(doc, update, "broadcast"). Because updates are idempotent and commutative, a tab that already has it via the network is unaffected. The draftSeq / isDraftBaseCurrent machinery is unnecessary here — out-of-order and duplicate delivery are both correct inputs to a CRDT.
  7. Compaction (cron, daily). For each note with COUNT(note_doc_updates) > 200 or SUM(octet_length(update_bin)) > 256 KiB: inside one transaction, take the advisory lock, load snapshot + tail, Y.encodeStateAsUpdate(doc) (a full doc load, which also drops deleted content — Y.mergeUpdates alone would not), UPSERT note_doc_snapshots with the new through_seq, then DELETE FROM note_doc_updates WHERE note_id = $1 AND seq <= $through_seq. Thresholds mirror y-postgresql's default of 200 transactions before auto-merge.

Tests

  • lib/crdt/convergence.test.ts — the load-bearing test. This is the Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 regression, restated:
    // Two docs from the same seed; each edits a different region while offline.
    const a = new Y.Doc(), b = new Y.Doc()
    Y.applyUpdate(a, seed); Y.applyUpdate(b, seed)
    a.getText('body').insert(0, 'PREFIX ')            // tab A prepends
    b.getText('body').insert(b.getText('body').length, '\nAPPENDED')  // tab B appends
    Y.applyUpdate(a, Y.encodeStateAsUpdate(b))
    Y.applyUpdate(b, Y.encodeStateAsUpdate(a))
    expect(a.getText('body').toString()).toBe(b.getText('body').toString())
    expect(a.getText('body').toString()).toContain('PREFIX')
    expect(a.getText('body').toString()).toContain('APPENDED')
    // Neither side's text was discarded. Under the current PUT path, one is lost.
  • Same test with a long-stale doc (B edits after being 50 updates behind) — the literal Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 scenario.
  • Out-of-order and duplicate update delivery both converge (asserts we can delete shouldAcceptDraftSeq).
  • note-doc.test.tsseedDocFromPlaintext round-trips exactly, including CRLF, emoji/surrogate pairs (the apply-external.ts surrogate hazard), and the ASCII-arrow substitution output from lib/arrows.ts.
  • note-doc-store.test.ts — compaction preserves docBodyFromState byte-for-byte and monotonically reduces stored bytes; through_seq never regresses; deleting compacted rows loses nothing.
  • Seeding idempotency: two concurrent seed attempts produce exactly one snapshot row and no duplicated body text.
  • Route tests: PUT with a body on a CRDT-backed note → 409 crdt_managed_body; oversized update → 413; garbage update → 400 and no row appended; another user's note id → 404.
  • Keep every existing test in lib/remote-apply-guard.test.ts and lib/save-failure.test.ts green — the legacy path is still live behind the flag.
  • Manual smoke: pnpm test, pnpm lint, pnpm build.

Edge Cases And Risks

  • Undo/redo double-history. yCollab's undoManager and @codemirror/commands' history() both bind ⌘Z. On CRDT-backed notes, remove history()/historyKeymap and use yUndoManagerKeymap + new Y.UndoManager(ytext). Y.UndoManager is per-client by design (you undo your edits, not a peer's) — that is the desired behavior, but it is a behavior change the operator will notice.
  • Two copies of Yjs. The classic silent failure: client and server, or two bundles, resolving different yjs instances → updates apply but nothing converges. Pin one version; verify with pnpm why yjs.
  • Seeding twice = duplicated content. Covered by server-only seeding + ON CONFLICT DO NOTHING + re-read. Explicitly warned about in the Hocuspocus docs. Must have a test.
  • Log growth. Updates are append-only and Y.mergeUpdates does not GC deleted content. Without the compaction cron a heavily-edited note's log grows without bound. Compaction must do a full doc load, not just mergeUpdates. Also add an octet_length guard so one runaway note cannot blow up the row size.
  • Projection lag. notes.body is eventually consistent with the log. If a /p/... read races a sync POST it may serve a body one update stale. Acceptable (today's poll is 1.5 s stale anyway) — but the projection write must never be skipped on the "no diff to return" path.
  • Serverless cold Y.Doc cost. Every /doc/sync POST loads snapshot + tail and re-encodes. With aggressive compaction this is a few KB and sub-millisecond, but it is real per-request CPU on Fluid pricing. Mitigation: the since === head fast path that skips the doc load entirely for no-op polls. Needs verification: measure p95 for a ~100 KB note before enabling the flag for daily use.
  • resolveCanonicalNoteId and aliases. note_doc_* rows key on the canonical id. The id-migration path in lib/db.ts::migrateLegacyNoteIds rewrites notes.id — the ON UPDATE CASCADE on both new FKs handles it, but verify against a note that still has an alias row.
  • Archive / permanent delete / purge. ON DELETE CASCADE drops doc rows with the note. Restore-from-archive must not resurrect a stale snapshot over a newer one — it cannot, since archive does not touch doc rows, but add a test.
  • Publish path. /p/{handle}/{id} reads notes.body via getPublicNote() with no auth. Unchanged — but confirm the projection write fires before published_at is set on a first publish.
  • Tenant isolation. Every doc route must resolve the id through userId. A room key of note_id alone would let a leaked id read another user's CRDT. Phase 2's onAuthenticate must verify the Clerk JWT and re-check ownership per documentName — a valid token for user A must not open user B's room.
  • Payload abuse. Cap decoded updates at 1 MiB and reject unparseable ones before insert; otherwise the log is an unauthenticated-shaped write amplifier for an authenticated user.
  • IME / composition. yCollab handles remote application through CM6 transactions, so the view.composing bail-out in apply-external.ts is not needed on this path. Still smoke-test Korean IME explicitly — this is the operator's primary input method and the historical source of editor bugs here.
  • Image widgets, arrow substitution, list continuation. These are CM6 extensions operating on the doc; they compose with yCollab (which is also just an extension). Regression-test all three, especially lib/editor/paste-images.ts and arrowPasteFilter(), which dispatch programmatic changes.
  • Flag flip-back. Once a note is seeded, the legacy PUT body path returns crdt_managed_body. Turning the flag off strands those notes read-only for body edits. Either keep a documented un-seed script (snapshot → notes.body, delete doc rows) or accept the flag as one-way and say so in the README.
  • Vercel WebSocket beta is a trap for Phase 2. It looks like the obvious answer and is not: pinned instances with no cross-instance broadcast cannot host a Yjs room. Re-verify before committing to it.

Non-Goals

  • No multi-user collaboration UI in v1. No avatars, no name badges, no follow-mode, no comments. Awareness is wired but carries a single local client.
  • No remote cursor rendering in Phase 1. yRemoteSelections stays off until Phase 3.
  • No WebSocket server in Phase 1. Reuse HTTP + the existing poll. Phase 2 is a separate PR and a separate deploy decision.
  • No managed vendor (Liveblocks / TipTap Collab / Ably) unless Phase 2 self-hosting is explicitly rejected.
  • Do not delete note_revisions (Add note body revision history so a bad overwrite is recoverable #52) or the lib/remote-apply-guard.ts predicates while the legacy path exists.
  • No change to publish//p/..., archive, themes, or any editor keybinding behavior.
  • No migration to Automerge or Loro. Evaluated and rejected for CM6 ecosystem maturity.
  • No offline-first rewrite of the notes list. Only the note body becomes CRDT; list metadata stays REST + poll.
  • No Yjs v14 / @y/y. Stay on the stable yjs@13.x + y-codemirror.next@0.3.x line.

Acceptance Criteria

  • note_doc_updates and note_doc_snapshots exist via ensureSchema(), are idempotent, and cascade on note delete.
  • GET /api/notes/:id/doc returns full state and seeds from notes.body exactly once per note, with no duplicated content under concurrent first opens.
  • POST /api/notes/:id/doc/sync appends, validates, projects to notes.body, and returns the caller's missing diff.
  • Two tabs typing simultaneously in the same note both keep their text. No 409, no discard prompt, no truncation.
  • Two devices editing the same note while both are dirty converge, and the merged body is identical on both after one sync round.
  • The literal Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 scenario — a stale tab that has been behind for many updates resumes typing — produces a merge, not a truncation.
  • notes.body matches docBodyFromState(snapshot + tail) after every sync (verified by test and by SQL spot-check).
  • /p/{handle}/{id} renders the current body with no code change to the public route.
  • note_revisions still records prior bodies on projection writes, coalesced at 60 s.
  • Undo/redo works with exactly one history implementation active; ⌘Z does not double-apply.
  • Caret and scroll position are preserved when a remote update lands mid-typing (the Chrome: editor viewport/caret jumps to top — full-document CM6 replacement collapses scroll anchor and selection to offset 0 #47 invariant, now by construction).
  • Korean IME composition is not interrupted by an inbound remote update.
  • Image widgets, ASCII-arrow substitution, Tab list indent, Enter list continuation, and strikethrough all behave as before.
  • A user cannot read or write another user's doc endpoints (404, no existence leak).
  • Compaction cron reduces stored bytes without changing docBodyFromState.
  • NEXT_PUBLIC_AGENTNOTE_CRDT=0 restores the exact current behavior; all existing tests pass in both modes.
  • pnpm test, pnpm lint, pnpm build clean.
  • README documents the flag, the tables, compaction, and the recovery path.

QA Plan

  1. pnpm install && pnpm test && pnpm lint && pnpm build.
  2. pnpm dev with NEXT_PUBLIC_AGENTNOTE_CRDT=1. Open the same note in two tabs. Type in both simultaneously in different paragraphs. Expect: both texts present in both tabs within ~1 frame; no error indicator; no confirm dialog.
  3. The Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 repro. Tab A: open note, then background it (poll pauses on hidden). Tab B: make 20+ edits. Return to A and immediately type at the top. Expect a merge containing both A's and B's text. Under main today, one side is truncated.
  4. Two devices (laptop + phone via the deployed preview, same Clerk user). Edit both at once. Expect convergence after one poll cycle, and notes.body in Postgres matching what both screens show.
  5. Offline: DevTools → Offline, type, go back online. Expect the buffered updates to flush and merge (Phase 1 queues in memory; Phase 1.5 survives reload).
  6. Mid-edit reload with unsaved text — Phase 1.5 acceptance.
  7. Publish the note, open /p/{handle}/{id} in a private window, confirm the body matches after a sync round.
  8. Archive → restore → permanent delete; confirm note_doc_* rows cascade (SELECT COUNT(*) FROM note_doc_updates WHERE note_id = '<id>' → 0). Read-only verification on a scratch note only.
  9. Trigger /api/cron/compact-note-docs with the CRON_SECRET bearer on a scratch note with 300+ updates; verify byte reduction and identical body.
  10. Set NEXT_PUBLIC_AGENTNOTE_CRDT=0; confirm legacy notes still save via PUT and every remote-apply-guard test stays green.
  11. Korean IME: type a long Hangul composition in tab A while tab B pushes updates. Expect no composition break and no character loss.

Suggested PR Scope

L overall — split into four PRs, in order. PR 1 alone must fix the data loss.

  1. PR 1 (M) — CRDT core, behind the flag. Deps, ensureSchema() tables, lib/crdt/*, both doc routes, use-note-doc, yCollab in codemirror-editor.tsx, doc-update on the BroadcastChannel, crdt_managed_body guard, convergence tests. Ships the fix.
  2. PR 2 (S) — Compaction + ops. Cron route, vercel.json entry, size guards, README.
  3. PR 3 (S) — Offline. y-indexeddb, sync-state UI, reconnect flush.
  4. PR 4 (M, optional/later) — Realtime transport. Hocuspocus on Railway (or y-partyserver on Durable Objects) behind the sync-transport.ts interface, Clerk JWT onAuthenticate with per-documentName ownership re-check. Requires a deploy decision — do not start without one.

Do not delete the legacy path or lib/remote-apply-guard.ts in any of these. A separate cleanup issue should retire them one release after the flag defaults to on.

Suggested next agent: $worktree-task$generate-pr for PR 1, scoped to Phase 1 only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions