ADR-0115: Collaborative Draft Editing — Two Carets, One Truth, Honest Credit¶
- Status: accepted (2026-08-12 evening — "go ahead with the hardening wave"; drafted the
same evening from his accepted options round on
docs/planning/collab-editing-options.md, "Agree with all" + his D3 contributor-set refinement) - Date: 2026-08-12
- Deciders: Nicolas Burri (#32: pair-writing without the half-step; the two-browser verdict: "the concurrent editor works very well"; the D3 refinement: the trail shows who contributed to a change)
- Relates to: ADR-0050 §3 (which reserved this ADR: "not to be built en passant"),
ADR-0110 (the trail records acts — never a keystroke log), ADR-0058 (trees + projection —
the save discipline that stays the only truth), register #32/#48, the W1 spike notes on
spike/collab-editing.
Context¶
The W1 spike proved #32's architecture: a dumb per-room WebSocket relay of opaque Yjs blobs (the server never parses a byte), TipTap's collaboration extensions on the visual surface, the EDIT_DRAFT gate asked through the same primitives as the save path, seeding server-nominated under a lock. Two browsers type into one paragraph; the save lands one coherent tree through the untouched path. What remains before main are the forks the spike recorded honestly instead of deciding.
Decision¶
- The room log becomes a RECOVERY BUFFER, never a truth (D1a). Persisted per version (opaque blobs), restored on the next join, deleted on save or draft discard. The existing save path remains the ONLY content persistence; the buffer is crash insurance a rejoining session reads — nothing else ever reads it. (Full CRDT-as-draft-truth stays a named W3 alternative with its own migration; not this ADR.)
- The SESSION holds the edit lock (D2a). Joining a session joins the lock;
expectedLockadvances for the room as one; the stale-content hint is structurally false between collaborators and does not fire inside a session. Solo editing outside a session keeps today's semantics byte-for-byte. - Honest credit, three layers (D3a+c+d, his refinement):
- the audited actor of a save is the person who performed it (DRAFT_EDITED, unchanged);
- the save's payload carries the CONTRIBUTOR SET — everyone who sent at least one content-update frame since the last save. Character-true without the server reading a byte: contribution is a frame-fact on an authenticated connection. The seed frame gets its own protocol type and never counts as contribution;
- COLLAB_SESSION_JOINED / COLLAB_SESSION_LEFT are their own trail events (actor = the joiner) — the cursor-only roster his fallback asked for. Per-character BLAME is refused: keystroke-log territory (ADR-0110), and lossy under compaction — the fact of contribution per interval is the decided shape, which is also #32's own words ("per-participant contribution over an interval").
- Reconnect ships with the merge (D4). A dropped connection rejoins through the same handshake and re-seeds from the recovery buffer; silent degrade-to-solo on a Wi-Fi blip is the one failure mode worse than a crash and does not survive this wave. Multi-instance stays a DOCUMENTED deployment constraint (the reference instance is single-instance) with a named trigger: the first planned second replica brings sticky sessions or a broker.
- Meta panels sync through the EXISTING SSE machinery (D5). A peer's meta change (roles, links, attachments) fires the refresh the rest of the app already understands — no CRDT for row-stores, no second sync surface. Regardless of collaboration, panels whose edits apply immediately (document-level rows) say so in place — his #48 discard test proved the current presentation implies a transaction that does not exist.
- No feature flag (D6). Draft-only by construction, degrade-to-solo proven by the full regression suite, the Live badge states the session truth. Config only at proven variation points.
The merge gate¶
All six decisions implemented and tested on the branch, PLUS: the two-Keycloak-user
fullstack test the spike could not write (the repo gains a two-user driver — the journey
infrastructure is the base); the netty WebSocket reachability note in
docs/security/vuln-triage.md refreshed for quarkus-websockets-next; the dependency audit
re-run over the six new MIT packages. Then spike/collab-editing merges to main and rides
the next release.
Consequences¶
- One new table (the recovery buffer), one protocol addition (the seed frame type), two new trail event types, the contributor set in DRAFT_EDITED payloads.
- The version-history display may surface "edited in a session with…" — display of payload facts, never new trail claims.
- W3 candidates parked with names: CRDT-as-draft-truth, source-mode collaboration (a shared projection is not the record), per-character attribution display (client-side, if ever).
Realization (2026-08-12)¶
Built on a hardening worktree off main, with spike/collab-editing merged in first and the
integration proved before anything was added. Artifacts: V100 collab_recovery_update,
CollabRecoveryRepository, DraftCollabSessions (the save path's port onto the rooms),
DraftCollabRegistry/DraftCollabSocket/DraftCollabPersistence, DocumentMetaChanged →
DOCUMENT_META_CHANGED, collab-session.ts (reconnect + the three-state badge), the #48a labels on
three panels (en + de). Tests: DraftCollabHardeningTest, CollabRecoveryRlsSeparationTest,
e2e/collab-hardening.spec.ts, and the merge gate itself, TwoUserCollabFullStackTest.
Three things the building found that the decision could not have known, recorded because each is a place a later reader would otherwise re-derive the hard way:
- The buffer must be a PREFIX of the room log, never a suffix. "Deleted on save" and "restored
on the next join" only compose if what remains is always a complete lineage from the room's first
frame. CRDT updates apply only within one document lineage, so replaying a suffix into a fresh
Y.Docproduces an EMPTY document (every update waiting on causal dependencies that were never persisted), not a partial one — and an empty editor is something a user can save over their real draft. So a save truncates to the empty prefix and SUSPENDS persistence until somebody types again, at which point the whole log is rewritten. That keeps "deleted on save" true in the database rather than true for an instant. - Degrade-to-solo is announced; never-having-had-a-relay is not. D4's target is the Wi-Fi blip that silently ends a live session. An editor that never established one is the baseline this surface has always had (no relay deployed, a mocked run, an offline start) — announcing that would be noise, and it is also what keeps the 140-test regression suite byte-identical.
- A
quarkus-websockets-nextendpoint bean is SHARED across connections. Only the injectedWebSocketConnectionis a per-callback contextual proxy, so per-connection state in a field is really "whoever connected last": two drafts co-edited at the same moment would have cross-relayed. Found by the spike's own awareness fan-out test the moment the seed frame gave the endpoint a second field to get wrong. All per-connection state now lives in the registry, keyed by connection id — the same lesson the spike already recorded about storing the connection itself.
The netty WebSocket reachability note in docs/security/vuln-triage.md is retired rather than
merely refreshed: the V07/V08 codecs are selectable by a client-supplied Sec-WebSocket-Version, so
a future advisory there can no longer be triaged on "no WebSocket server here". Verdict and
remediation for the existing five findings are unchanged (4.1.136.Final already carries the fixes).
Amendment 1 (2026-08-23) — a WINDOW joins the lock, not a person¶
D2a's sentence "joining a session joins the lock" was written, and implemented, with the person as the unit: room membership matched on user id. A live test by one person with the same draft open in two windows (one in the room, one in source mode) proved the unit wrong — the outside window's save was classified as a participant save, so no supersede fired and the session lock advanced to the very save the D2a fast path then destroyed. Every automated test had used different users per context, which is why only the human found it.
The unit of session membership is the WINDOW. Every seat and every saver carries
(userId, windowId) — the window id being the client's per-page-load nonce presence already uses —
and a saver naming no window (REST, MCP, integrations) is an outsider by construction, which is
what it is. Additionally, every advance of a draft's optimistic lock now flows through ONE choke
point that notifies the room as part of the same act, with an explicit, guard-tested inventory of
the (currently two) lock-advancing paths and their classification (CONTENT → full supersede
machinery; METADATA → session-lock correction only).