-- +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;
