DocumentationDecision recordsADR-0034
Annotation anchors are document data, conversations are not
A comment thread has two halves that want different homes. The anchor — which text is being commented on — only means anything relative to the document, and…
accepted
Context
A comment thread has two halves that want different homes. The anchor — which text is being commented on — only means anything relative to the document, and must survive every edit above it, undo, split-view, Markdown export, and import into another editor. The conversation — bodies, timestamps, resolution — is workspace content: it is queried per note, replicated by sync, and must remain readable after the text it points at is deleted.
Storing both in the document would put comment bodies into every Markdown export and force a full document save on each reply, violating the editing performance contract. Storing both in the workspace would need positional offsets, which every keystroke above the anchor invalidates.
Decision
Split them at the identity boundary. The document carries a ProseMirror
annotation mark whose only attribute is a threadId; the workspace carries a
WorkspaceAnnotation entity keyed by that id. Position is expressed solely by
where the mark sits, so ProseMirror's own mapping keeps anchors correct through
arbitrary edits and no offset is ever persisted.
The mark is inclusive: false (typing at an anchor edge stays outside the
thread) and excludes: "" (threads may overlap and nest). It serializes as
<mark data-skriuw-annotation="…">, so another editor sees the anchored text
with harmless markup rather than losing it.
Three consequences of the split are settled here:
- The thread stores an
anchorTextsnapshot taken at creation. A thread whose anchor is deleted is not destroyed; it becomes unanchored, still listed and still readable, labelled from that snapshot. - A thread does not outlive its note. Deleting a note purges and tombstones its threads. This deliberately differs from tasks (ADR 0031), which detach from their block and survive, because a comment without its note has no subject at all.
- Deleting the last comment keeps the thread. An empty thread is a legible state; destroying it implicitly would silently discard the anchor.
Consequences
Replying, editing, resolving, and reopening are workspace operations that never touch the document, so they cost no document save and no re-parse. Only creating a thread and deleting a thread write both halves, and each does so in one operation batch.
Markdown gains one <mark> element per anchor. Because highlight already
serializes to <mark>, the close tag is ambiguous and the inline parser must
track open tags on a stack to attribute </mark> correctly; any future mark
serializing as <mark> joins that stack. This is the price of portable anchors
and is accepted over a private fence syntax that other editors would show raw.
Threads are found by walking the document for marks, which is bounded by the note, not the workspace. Both the inspector list and the two navigation shortcuts resolve against the full document rather than the bounded editor window, so a thread anchored outside the visible window is reachable rather than reported missing.
Implementation contract: docs/specs/annotations.md.
On this page