SPEC-0006: Annotations — Reactions & Comments
Overview
Every Cairn share type carries the same two social affordances: emoji reactions
and threaded comments. This capability formalizes the unified annotation
subsystem that serves all share types over one reactions path and one comments path,
across the web, CLI, and MCP surfaces. It realizes ADR-0006 (the polymorphic
{artifact_id, anchor_type, anchor_ref} anchor model, idempotent reactions, shallow
threaded comments, registry-gated anchor capabilities, and two-tier count
aggregation) and depends on SPEC-0002 for the artifact core, the share-type
registry that owns each type's anchor capability sets, and content-addressed immutable
bodies.
Annotations attach to type-specific anchors whose legal set is declared per share type by the ADR-0002 registry: a markdown block or bullet, a code line or selection, an image region pin, a webhook request, a trajectory turn / tool-call / span, or the whole artifact. A deliberate asymmetry is enforced structurally: webhook requests are reactable but not comment-threaded. Counts roll up into the Bin and artifact headers. The REST API is auth-by-default: posting a reaction or comment requires authentication, while reading annotations follows the annotated artifact's ADR-0007 link-capability.
The per-viewer anchor affordances (how a viewer surfaces and locates an anchor) live in the viewer specs (SPEC-0003, SPEC-0004, SPEC-0005). This spec owns the anchor model, validation, persistence, idempotency, threading, aggregation, and the API.
Requirements
Requirement: Polymorphic Anchor Model
Reactions and comments MUST share one embedded anchor of three fields:
an annotation is created with a given the service MUST store an annotation targets the whole artifact its artifact_id (the annotated artifact), anchor_type (a registry-owned discriminator
string), and anchor_ref (a JSONB locator whose schema is defined per anchor_type).
The service MUST compute a canonical anchor_key — the sorted-key, whitespace-free
serialization of anchor_ref — and use it for uniqueness and indexing so ordering of
JSON fields never affects identity. Every annotation query MUST be scoped to a single
artifact_id.Scenario: Anchor persisted with canonical key
anchor_refanchor_type, anchor_ref, and a canonical
anchor_key, and two anchor_refs differing only in JSON key order MUST produce the
same anchor_keyScenario: Whole-artifact anchor
anchor_type MUST be artifact and its anchor_ref MUST be {}
Requirement: Registry-Gated Anchor Capabilities
Each share type's ADR-0002 registry entry MUST declare two capability sets: which
a client comments with an the server MUST reject the write with an the server MUST reject the write with anchor_type values accept reactions and which accept comments. On every
write the service MUST reject a reaction or comment whose anchor_type is not in the
corresponding capability set for the artifact's share type, and MUST reject an
anchor_ref that fails that anchor_type's locator schema. Adding a share type
(SPEC-0003 registration) MUST define its full annotation surface as registry data with
no schema change to this subsystem.Scenario: Illegal anchor type for a type
anchor_type outside the share type's comment
capability setvalidation_failed and persist nothingScenario: Malformed locator
anchor_ref does not match its anchor_type's locator schemavalidation_failed
Requirement: Webhook Reaction-Only Asymmetry
The webhook share type's registry entry MUST accept reactions on a client posts a comment anchored to a the server MUST reject it with a client reacts to a the server MUST accept and persist the reactionartifact and
webhook_request anchors and MUST accept comments on no anchor type. The service
MUST therefore reject any comment on any webhook artifact while accepting reactions on
a webhook_request. This rule MUST be enforced as registry data, not special-cased
code.Scenario: Comment on a webhook request refused
webhook_requestvalidation_failed and persist nothingScenario: Reaction on a webhook request accepted
webhook_request
Requirement: Idempotent Reactions
A reaction MUST be uniquely identified by an actor reacts 🔥 to an anchor they have already reacted 🔥 to the result MUST remain a single reaction row (upsert no-op) an actor removes their 🔥 reaction from an anchor exactly that reaction row MUST be deleted and others MUST be unaffected(artifact_id, anchor_type, anchor_key, emoji, actor_id). Reacting with the same emoji to the same anchor twice by the same
actor MUST be a no-op (a single stored row), and un-reacting MUST delete exactly that
row. The emoji MUST be a single Unicode grapheme.Scenario: Duplicate reaction
Scenario: Un-react
Requirement: Threaded Comments
Comments MUST support one level of reply nesting via a nullable a client replies to a root comment the reply MUST reference that root via a root comment with replies is deleted it MUST be soft-deleted (tombstoned) and its replies MUST remain resolvableparent_id (a thread
root has parent_id = NULL; a reply references its root). A reply's anchor MUST match
its root's anchor. Deletion MUST be a soft delete (deleted_at) that preserves
thread structure; edits MUST record edited_at. Deeper-than-one-level nesting MUST NOT
be created.Scenario: Reply to a comment
parent_id and share its anchorScenario: Soft-deleted comment keeps the thread
Requirement: Count Aggregation
The subsystem MUST maintain two tiers of counts. (1) Artifact-level rollups —
the Bin lists artifacts each row's reaction/comment counts MUST read from the artifact's
denormalized counters, not a per-row scan of the annotation tables a single artifact is opened its per-anchor emoji tallies (e.g. comment_count, reaction_count, and pin_count denormalized on the artifact and
updated in the same transaction as the annotation insert/soft-delete — so the Bin
row (💬 2 · 👀 3) and artifact headers (6 comments · 16 reactions, 2 pins · 8 reactions) render with no per-row subquery. (2) Per-anchor tallies at view time —
emoji tallies, per-anchor grouping, and the viewer's "did I already react" flag —
computed with a GROUP BY (anchor_key, emoji) scoped to the one opened artifact.Scenario: Bin count without subquery
Scenario: Per-anchor emoji tally
🔥 7 · 🙏 3) and the current actor's
"already reacted" flags MUST be computed over that one artifact's reactions
Requirement: Anchor Stability
Anchors MUST resolve reliably because their substrate is immutable: artifact bodies are
content-addressed (ADR-0008) and editing mints a new artifact, never mutates one.
Render identifiers MUST be deterministic — markdown an anchored annotation's artifact is re-rendered on any surface the anchor MUST resolve to the same location it was created against a the annotation MUST be surfaced as "context changed" rather than mis-anchoredblock_ids hashed from structural
position + content, code line numbers intrinsic to the body (optionally with a line-
text hash), image regions in normalized fractional coordinates, and webhook
request_ids / trajectory span_ids assigned once at capture. A text_selection
anchor MUST store character offsets and the quoted substring so it can be
re-highlighted and flagged "context changed" if the quote no longer matches.Scenario: Anchor resolves on re-render
Scenario: Text selection quote mismatch
text_selection anchor's stored quote no longer matches the body at its
offsets
Requirement: Cross-Surface Parity
Reactions and comments MUST behave identically whether created over the REST API, the
CLI, or MCP (ADR-0003); all three surfaces MUST be thin adapters over the same core
service methods. An annotation created on one surface MUST render on the others against
the same anchor. an agent reacts over MCP and a human comments over REST against the same
anchor both MUST render identically in the web viewer against that anchorScenario: MCP reaction visible on the web
Requirement: Error Handling Standards
Domain failures MUST use sentinel errors that callers distinguish (e.g. anchor-not-
allowed, locator-invalid, comment-on-non-commentable-type, parent-not-found), and the
transport adapters MUST map each to a stable error an annotation write fails registry validation the service MUST return a distinguishable sentinel error that the adapter
maps to a lower-layer error occurs during an annotation write it MUST be wrapped with context and surfaced, not discardedcode (ADR-0012:
validation_failed, not_found, forbidden, conflict, …) without string matching.
Errors MUST be wrapped with context at each layer boundary (preserving the chain), MUST
NOT be silently swallowed, and MUST be logged with structured key-value fields
including request_id, artifact_id, and anchor_type.Scenario: Domain error mapped to a stable code
validation_failed in the structured error envelope with a request_idScenario: No silent swallow
Requirement: Database Operation Standards
A mutation that both writes an annotation and updates the artifact's denormalized
counters MUST occur in a single transaction so a count can never diverge from its
rows on the committed path. All SQL MUST use parameterized queries (bound
parameters); no query SHALL be assembled by string concatenation of caller input.
Database calls MUST honor the request a comment is inserted the insert and the artifact any annotation query includes caller-supplied values (emoji, body,
they MUST be passed as bound parameters, never string-concatenated into SQLcontext.Context deadline/cancellation and use
explicit connection lifecycle with timeouts.Scenario: Atomic write + counter update
comment_count increment MUST commit in one
transaction, or neither is appliedScenario: Parameterized query only
anchor_ref)
Security Requirements
This capability is web-facing. The following are MANDATORY.
Requirement: Authentication & Authorization
Posting a reaction or comment (and un-reacting, editing, deleting) MUST require
authentication (session for web, OAuth 2.1 bearer for API/MCP/CLI per ADR-0004).
Reading annotations MUST enforce the annotated artifact's ADR-0007 link-capability:
only a client that can read the artifact MAY read its annotations. Editing or deleting
a comment MUST be limited to its author (or an authorized workspace role). Agents MUST
NOT exceed the human's permissions. an unauthenticated client posts a reaction or comment the server MUST respond 401 and persist nothing a client without the artifact's link capability requests its annotations the server MUST deny the read per the access policy and return no annotationsScenario: Unauthenticated post
Scenario: Read without artifact capability
Requirement: Rate Limiting
All annotation endpoints MUST be rate-limited per-identity/per-IP; exceeding the limit
MUST return 429 with a client exceeds the configured reaction/comment rate the server MUST respond 429 with Retry-After. Reaction toggling and comment posting MUST be
throttled to resist spam/flooding.Scenario: Reaction flood
Retry-After without processing the request
Requirement: Security Headers
Annotation responses (including HTMX comment fragments) MUST set a strict
a comment body contains script or HTML it MUST be sanitized/escaped so it cannot run when rendered in the viewerContent-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy, and
(over HTTPS) HSTS. User-supplied comment bodies MUST be sanitized/escaped on render so
they cannot execute in Cairn's app origin.Scenario: Comment body with active content
Requirement: Request Body Size Limits
Every annotation endpoint MUST enforce a maximum request size — bounding comment body
length and rejecting oversize payloads with 413 before buffering the full body. a comment body exceeds the configured maximum the server MUST reject it with 413 and persist nothingScenario: Oversize comment
Requirement: CSRF Protection
Cookie/session-authenticated annotation writes MUST be CSRF-protected (token or
SameSite strategy). Token-authenticated API/MCP requests are exempt (no ambient
credentials). a session-authenticated comment post arrives without a valid CSRF token the server MUST reject itScenario: Cross-site comment post
Requirement: Redirect & SSRF Validation
Any redirect issued after an annotation action MUST target only an allow-listed
internal path; no user-supplied absolute URL SHALL be honored. No annotation input
(comment body, an annotation request supplies an external redirect target the server MUST ignore it and redirect only to a safe internal pathanchor_ref) SHALL trigger a server-side fetch of a user-supplied URL;
if one is ever added it MUST be SSRF-guarded.Scenario: Open-redirect after posting
Accessibility Requirements
This capability renders user-facing UI. WCAG 2.1 AA is the minimum target.
Requirement: WCAG 2.1 AA & Semantics
Reaction clusters and comment threads MUST meet WCAG 2.1 AA. Reactions MUST NOT be
conveyed by color/emoji alone — each cluster MUST expose an accessible name and count
(e.g. "🔥 fire, 7 reactions"). Comment threads MUST use correct list/heading semantics
and associate each comment with its author and timestamp. a reaction cluster is shown it MUST expose a text label and count to assistive technology, not rely on
the emoji glyph aloneScenario: Reaction not color-only
Requirement: Icon-Only Controls
Every icon-only annotation control — the react the react it MUST expose an + picker trigger, an emoji button, a
reply/edit/delete affordance, an image pin — MUST have an aria-label describing its
action.Scenario: React plus button
+ control has no visible text labelaria-label describing its action
Requirement: Dynamic Content Regions
Newly posted comments and reaction updates (HTMX swaps) MUST land in an a comment is posted and swapped into the thread it MUST be announced via an aria-live
region (polite) so assistive technology announces them without stealing focus.Scenario: New comment announced
aria-live region
Requirement: Keyboard Navigation & Focus Management
All annotation controls MUST be keyboard-operable with a logical tab order: Enter/Space
activate, Escape dismisses the picker/popover, arrow keys move within the emoji picker.
The reaction picker and comment popover MUST trap focus while open and restore focus to
the trigger on close. a keyboard user opens the reaction picker focus MUST move into it, cycle within it, and return to the trigger on closeScenario: Keyboard-only reaction
HTTP Endpoints
REST/JSON under /v1 (ADR-0012), all backed by the one core service. Auth-by-default:
reads are Auth: Public only because they are gated by the annotated artifact's
ADR-0007 link capability; every mutation is Auth: Required.
| Method & Path | Purpose | Auth |
|---|---|---|
GET /v1/artifacts/{id}/reactions | List reactions (grouped tallies + "did I react") | Public — gated by artifact link capability (ADR-0007) |
POST /v1/artifacts/{id}/reactions | React (idempotent; registry-gated anchor) | Required |
DELETE /v1/artifacts/{id}/reactions/{rid} | Un-react (delete own reaction) | Required |
GET /v1/artifacts/{id}/comments | List comment threads for the artifact | Public — gated by artifact link capability (ADR-0007) |
POST /v1/artifacts/{id}/comments | Comment (registry-gated anchor; refused on non-commentable types) | Required |
The MCP tools (ADR-0004) and the HTMX handlers (ADR-0011) are thin adapters over the same core methods these routes call, mechanically guaranteeing cross-surface parity.