Decision record
ADR-0007: Comment anchors are sections and tasks
ADR-0007: Comment anchors are sections and tasks, not editor ranges
Status: accepted · 2026-09-11
Context
The comment record has carried an anchor field since Phase 4, documented as
a “ProseMirror/Yjs-relative range anchor; null = page-level thread”. Nothing
ever wrote it. The web use case and the MCP add_comment tool both created
page-level comments, so every comment on every page sat in one flat list at
the bottom, and an approver reviewing a plan could not point at the task they
meant.
Two things forced the question at once. A rejection carried no reason — the status moved and the submitter learned nothing — which made the review gate a wall rather than a conversation. And an agent that had published a brief over MCP needed to read what a person objected to and where, so it could change that part and resend, rather than guess.
The field’s original intent was the Google Docs model: a Yjs relative position that survives concurrent edits and lets a comment sit beside a sentence. That is the right model for prose reviewed by people in an editor. It is the wrong model for this product, for three reasons that do not go away with more engineering:
- It cannot be expressed over MCP. A client has no ProseMirror document and no
Yjs state; the only names it has for parts of a page are the ones
get_pagereturns. - It cannot be rendered where the editor is not — the read-only view a viewer gets, an export, an email. A range is meaningful only inside the document it indexes.
- It is fragile in the editor’s own hands. Relative positions survive edits to other text; they do not survive the anchored text being rewritten, which is exactly what a reviewer’s comment tends to cause.
Decision
A comment anchors to a section, a task, or the page. Never to a range.
{ kind: 'section', anchor: 'the-decision' }
{ kind: 'task', index: 2, text: 'Author identity — …' }
null
These are the identifiers the product already uses to address parts of a
page: update_section takes a heading anchor, set_task takes a task index
or text prefix. A reviewer in the app and an agent over MCP are therefore
pointing at the same thing with the same name. The task anchor stores both
index and a text snapshot, the same pair set_task matches on, so a
reordered list can still be re-matched.
An anchor is validated against the page as it is now, on write. A comment pointing at a heading or task that does not exist is refused, not silently demoted to page level: the reviewer meant a specific place.
Two things ride on the same change, because they are what the anchoring was for:
- A decision carries a note, stored as a comment. Approve and reject both
accept one; reject requires it. The comment carries
decision: 'approved' | 'rejected', and the status-change activity carries the comment’s id, so the audit trail and the conversation point at each other. Not a separate “review note” entity — a comment that happens to be a decision is still a comment, and a second list would be a second place to read. - Replies, via
parentId, one level deep. A reply to a reply attaches to the root, so resolution — which lives on the root — covers the whole exchange. Replies inherit the root’s anchor.
Alternatives rejected
Editor-range anchoring, as originally intended. Rejected for the three reasons in Context. The strongest version of the case for it — a comment on a specific sentence — is a real feature for prose, and it is not what a reviewer of a plan or brief needs, which is “this task” or “this section”.
Anchoring by line number. Trivially expressible, trivially broken by any edit above the line, and meaningless once the Markdown is rendered.
A separate review-note record on the page. Cleaner in the data model, worse for every reader: a person would read notes in one panel and comments in another, and an agent would call two tools to learn one thing.
Threads of arbitrary depth. Deeper nesting makes resolution ambiguous and buys nothing a plan review needs. One level is a conversation; two is a forum.
Consequences
- The editor schema is untouched. No new node type, no migration, no Markdown/export path to add. This was a deciding factor, not a side effect.
get_pagereports open comment counts per section and per task, so an agent sees where the pushback is without a second call.- A comment can outlive its anchor — a heading renamed, a task deleted. The comment stays, the label falls back to the stored anchor text, and nothing breaks. This is the correct failure: a reviewer’s words are not deleted because the author restructured the page.
- The
/homereview queue gains Reject, with the same required note. The queue was the one place approving was a single click; rejecting now is too, once the reason is typed. - The requester is told. The digest that already tells reviewers about requests and approvers about withdrawals now tells requesters about decisions, in the reviewer’s own words. A decision on a service member’s brief goes to the person who owns that member.
page.rejectedjoinspage.approvedas a webhook event, carrying the note’s comment id indata.commentId. An integration that published a brief can fetch the reason and republish, which closes the loop the integrations page describes.- Comments remain members-only and never reach a published page.
Not decided here
Suggested edits — a reviewer proposing specific replacement text — are a different feature with a different data model, and the section anchor is probably its natural home. Nothing in this decision forecloses it.
References
- Model Context Protocol specification — the surface the anchors had to be expressible over
- Yjs — the range model that was rejected