-- +brant Up
-- Inline comments on a proposal, anchored to a block of a document.
--
-- The anchoring model was held back until the review UI existed (see the note
-- this migration removes from schema.sql), because a schema is the expensive
-- thing to get wrong here. What the built UI settled: comments live on
-- proposals, any block of a proposed document can carry one, and the anchor is
-- content-first.
--
-- The stored tuple is (doc_id, heading_path, block_index, block_hash). Line
-- numbers are deliberately absent: prose reflows, so a one-word edit moves
-- every line below it, which is the same property that made a line-oriented
-- differ useless for this corpus.
--
-- WHAT IS NOT STORED: whether a comment still fits. A comment is not outdated
-- in general — it is outdated *at a revision*, and a proposal branch moves
-- under it as the agent revises. Anchor state is derived at read time by
-- core.ResolveAnchor against the revision being displayed. A column here would
-- be a cache of a function of a moving input, and would be wrong every time the
-- agent pushed.
CREATE TABLE comment (
id SERIAL PRIMARY KEY,
proposal_id INTEGER NOT NULL REFERENCES proposal(id) ON DELETE CASCADE,
-- A reply. One level only: a thread is a root plus its replies, which is
-- what a review conversation with one human and one agent actually is.
-- Enforced in db/comment.go, since a CHECK cannot look at another row.
parent_id INTEGER REFERENCES comment(id) ON DELETE CASCADE,
-- The anchor. NULL on a reply, which inherits its root's rather than
-- carrying a copy that could drift from it.
doc_id TEXT, -- archive addressing key: "SPEC-0007", or the path
doc_path TEXT, -- path as at comment time; display, and which diff it belongs to
heading_path TEXT[], -- enclosing headings, outermost first
block_index INTEGER, -- position within heading_path, NOT within the document
block_hash TEXT, -- prosediff block hash when the comment was written
side TEXT, -- new | old ("old" only for a block the proposal deletes)
body TEXT NOT NULL,
author TEXT NOT NULL, -- owner username, or "claude-code/spec-writer"
author_kind TEXT NOT NULL, -- human | agent
agent_session TEXT,
created TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Set when the owner resolves the thread. Agents may not resolve — an agent
-- marking its own critique resolved would defeat the auto-merge gate below
-- — which is enforced in the service layer, where the caller's identity is
-- known; this column only records that it happened.
resolved TIMESTAMPTZ,
CONSTRAINT ck_comment_body CHECK (length(btrim(body)) > 0),
CONSTRAINT ck_comment_author CHECK (length(author) > 0),
CONSTRAINT ck_comment_author_kind CHECK (author_kind IN ('human', 'agent')),
-- Provenance, on the same rule as proposal: the identity strings, not the
-- credential, are what identify who said what, so an agent comment without
-- a session is not a comment with a missing field — it is an unattributable
-- one. NOT NULL alone would accept the empty string and lose that.
CONSTRAINT ck_comment_provenance CHECK (
(author_kind = 'agent') = (agent_session IS NOT NULL AND length(agent_session) > 0)
),
-- A root carries the whole anchor and a reply carries none of it. Written
-- as one predicate over every anchor column so a half-populated anchor —
-- the shape a partial write would leave — cannot be stored at all.
CONSTRAINT ck_comment_anchor CHECK (
(parent_id IS NULL) = (doc_id IS NOT NULL)
AND (doc_id IS NULL) = (doc_path IS NULL)
AND (doc_id IS NULL) = (heading_path IS NULL)
AND (doc_id IS NULL) = (block_index IS NULL)
AND (doc_id IS NULL) = (block_hash IS NULL)
AND (doc_id IS NULL) = (side IS NULL)
),
CONSTRAINT ck_comment_side CHECK (side IS NULL OR side IN ('new', 'old')),
CONSTRAINT ck_comment_block_index CHECK (block_index IS NULL OR block_index >= 0),
-- Resolution is a property of the thread, not of one message in it.
CONSTRAINT ck_comment_resolved CHECK (resolved IS NULL OR parent_id IS NULL)
);
-- The review page reads a proposal's whole thread set in one go.
CREATE INDEX ix_comment_proposal ON comment (proposal_id, created);
-- Replies, by thread.
CREATE INDEX ix_comment_parent ON comment (parent_id) WHERE parent_id IS NOT NULL;
-- The auto-merge gate asks one question — "does this proposal have an
-- unresolved thread?" — on every policy merge, so it gets its own partial
-- index rather than scanning a proposal's comments to answer it.
CREATE INDEX ix_comment_unresolved ON comment (proposal_id)
WHERE parent_id IS NULL AND resolved IS NULL;
-- +brant Down
DROP TABLE comment;