Files
multica/server/pkg/db/queries/comment.sql
Bohan Jiang 69744241e8 fix(comment): restore autopilot @mention delegation authority (MUL-4857) (#5512)
* fix(comment): restore autopilot @mention delegation authority (MUL-4857)

A schedule/webhook autopilot run is unattributed by design (no top-of-chain
human originator, MUL-4302). Since MUL-3963 the A2A invoke gate (canInvokeAgent)
keys on that originator, so a mid-run @agent/@squad delegation on an
autopilot-created issue fails closed for the DEFAULT private agent (and
member-scoped public_to agents): the mention renders but no run is enqueued.
The SAME autopilot's first dispatch is admitted via the autopilot creator
(autopilotAdmitInvoke -> canCreatorInvokeAgent), so first-dispatch and mid-run
delegation disagreed.

Align them: when an unattributed agent/system-authored comment on an
autopilot-origin issue reaches computeCommentAgentTriggers with no originator,
fall back to the autopilot creator as the effective invoking user for the gate.
The gate still runs (no unrestricted agent-to-agent bypass); it is authorization
only -- the enqueued task's originator/attribution stays unattributed. Scoped to
autopilot-origin issues so other unattributed chains stay fail-closed.

Adds a DB-backed regression test covering: creator-owns-target admits, a
non-autopilot unattributed run stays denied, and a creator without invoke rights
stays denied.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comment): bind autopilot @mention authority to verified task lineage (MUL-4857)

Address the review's confused-deputy finding on the P0 fix. The first cut keyed
the invoke-gate fallback on issue provenance + an empty originator alone
(invokeAuthorityForAutopilotIssue took only the issue), so any unattributed run
could borrow a stranger autopilot creator's rights merely by commenting on that
autopilot's issue — and the fallback also leaked past explicit @mention into the
plain-comment squad-leader path and system actors.

Rework it so the autopilot-creator authority is granted ONLY when the SPEAKING
task's lineage is verified against this issue:

  - resolve the authority separately (new AutopilotDelegationAuthorityUserID on
    commentTriggerComputeOptions), never by overwriting OriginatorUserID; the
    gate reads it through opts.effectiveInvoker() only when no human originator
    resolved, so attribution stays untouched;
  - resolve from a server-trusted speaking task — X-Task-ID on create/preview,
    comment.source_task_id on edit/reconcile — via autopilotDelegationAuthority,
    which admits only when author == task agent AND task.issue_id == this issue
    AND the issue is autopilot-origin, then keys on the member autopilot creator;
  - do NOT key on autopilot_run_id: in create_issue mode (the reported case) the
    leader task is enqueued through the ordinary issue-assignment path and has no
    autopilot_run_id — the task.issue_id == issue binding is what proves the run
    is part of this autopilot's work while rejecting foreign-issue runs.

Tests: replace the provenance-only regression with lineage-bound coverage —
verified-lineage-admits, creator-without-rights-denied, non-autopilot-denied,
missing-source-task-denied, cross-issue-source-task-denied, author!=task-agent-
denied — plus an end-to-end CreateComment path asserting the private worker is
enqueued and the delegated run stays unattributed. Verified the fallback is
load-bearing (positive + e2e fail with it disabled) and the full internal/handler
package passes. Skill docs (multica-mentioning) updated to the lineage-bound
contract and new helper names.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comment): make autopilot @mention authority consistent across defer/edit (MUL-4857)

Second review round (Elon) surfaced two must-fixes on top of the lineage binding.

1. Busy-target completion reconcile lost the authority. A delegation to a target
   that is already running is deferred to that target's completion reconcile
   (reconcileCommentsOnCompletion). That path recomputed triggers with only the
   (empty) originator, so an unattributed autopilot delegation's follow-up was
   gate-denied again and silently dropped. It now restores the delegation
   authority from comment.source_task_id, so the follow-up fires once the target
   frees up — still unattributed.

2. Edit could borrow the old authoring run's authority, and preview != save. The
   edit preview keyed authority on the current request task while save keyed it on
   the comment's original source_task_id, so an agent editing its old autopilot
   comment from a task on an UNRELATED issue would fail-closed in preview but reuse
   the old autopilot creator's authority on save (cross-issue confused-deputy, and
   a preview/side-effect divergence). Fix: treat source_task_id as the persisted
   per-action authority lineage and re-stamp it on edit to the CURRENT editing
   task, issue-scoped exactly like CreateComment. A cross-issue edit re-stamps it
   to NULL, so preview, save, AND the deferred reconcile all fail closed
   identically. UpdateComment query gains a source_task_id param (sqlc regen).

Also locks the review-accepted behavior that effectiveInvoker() carries the
autopilot-creator authority into the plain assigned-squad-leader wake (a worker's
result comment on the autopilot issue can still wake the private leader).

Tests: reconcile-restores-authority (owns -> one unattributed follow-up; no rights
-> none); edit re-stamp (same-issue keeps authority and triggers; cross-issue
clears source_task_id and fails closed); worker-result wakes private squad leader.
Verified both fixes are load-bearing (each negative control reproduces the exact
regression Elon described), full internal/handler + internal/service packages pass,
gofmt/vet clean. Skill docs (multica-mentioning) updated.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comment): clear stale task lineage on non-author comment edits (MUL-4857)

An admin editing an autopilot Agent's comment previously preserved the
comment's original source_task_id. The immediate save is judged on the
admin's member identity and correctly fails closed, but the deferred
completion-reconcile routes the comment under its original agent author
and resolved the delegation authority from the stale source_task_id,
resurrecting the autopilot creator's invoke authority once the busy
target freed up — an admin (manage rights) could thereby trigger another
owner's private agent (invoke rights).

Now a content edit re-derives lineage from the edit action: only the
agent author editing its own comment re-stamps source_task_id to the
current editing task; every other editor (member/admin, or any
non-author) clears it, so preview, save, and reconcile all fail closed.

Adds a regression covering the admin-edit + busy-target path and syncs
the multica-mentioning skill docs.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 15:23:56 +08:00

494 lines
21 KiB
SQL

-- name: ListCommentsForIssue :many
-- All comments for an issue in chronological order, capped at $3 (DB safety
-- net). Issue p99 is ~30 comments, max ever observed in prod is ~1.1k, so
-- the handler-side cap of 2000 is purely defensive.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC, id ASC
LIMIT $3;
-- name: ListCommentsSinceForIssue :many
-- Comments created strictly after $3 in chronological order, capped at $4.
-- Powers the CLI's `--since` agent-polling flow.
SELECT * FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC, id ASC
LIMIT $4;
-- name: ListRootCommentsForIssue :many
-- Top-level comments only, in issue chronological order, each annotated with
-- per-thread orientation stats: reply_count (number of descendants) and
-- last_activity_at (MAX(created_at) over the whole subtree). This powers
-- `comment list --roots-only` so agents can not only orient around the global
-- discussion but also triage which thread to drill into (biggest / most
-- recently active) before fetching any specific reply thread.
--
-- `selected_roots` picks the roots we will actually return first (the chrono
-- page of size @row_limit), so the recursive `membership` walk only expands
-- those threads' subtrees instead of every thread in the issue. membership
-- labels each comment with its thread root by walking down from the selected
-- roots, so the counts stay correct even if the schema ever allows
-- reply-of-reply (the write path collapses to root today, but does not enforce
-- it). Mirrors ListRecentThreadCommentsForIssue's stats CTE.
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
ORDER BY c.created_at ASC, c.id ASC
LIMIT @row_limit
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC;
-- name: ListRootCommentsSinceForIssue :many
-- Top-level comments created strictly after @since, each annotated with the
-- same reply_count / last_activity_at stats as ListRootCommentsForIssue. The
-- @since filter narrows which roots are returned; the stats are still computed
-- over each selected thread's full subtree (so a freshly created root with no
-- replies reports reply_count 0 and last_activity_at = its own created_at).
-- selected_roots applies the @since + @row_limit cut up front so the recursive
-- membership walk only touches the subtrees of the roots we actually return.
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
AND c.created_at > @since
ORDER BY c.created_at ASC, c.id ASC
LIMIT @row_limit
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC;
-- name: ListThreadCommentsForIssue :many
-- Returns the root of the thread containing @anchor_id plus every descendant
-- (recursive — supports real reply-to-reply nesting). @anchor_id may itself be
-- a root or any reply in the thread. Output is chronological so it can be fed
-- straight to the agent.
WITH RECURSIVE root_of AS (
-- Walk up from the anchor until parent_id IS NULL.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @anchor_id AND c.issue_id = @issue_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Start from the root, then keep adding any comment whose parent is
-- already in the set. Cycle-safe under PK constraint (a comment cannot
-- be its own ancestor).
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = @issue_id AND c.workspace_id = @workspace_id
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM descendants
ORDER BY created_at ASC, id ASC
LIMIT @row_limit;
-- name: ListThreadCommentsForIssuePaged :many
-- Same root-walk + descendants expansion as ListThreadCommentsForIssue, but
-- returns root + only the @reply_limit most recent replies (per the
-- (created_at, id) composite key). When @has_cursor=TRUE only replies with
-- (created_at, id) < (@before_at, @before_id) are eligible — that is the
-- cursor for scrolling *within* a thread.
--
-- Root is unconditional: it is included regardless of @reply_limit (even 0)
-- and regardless of the cursor. A reader landing on a long thread needs the
-- root for the "what is this thread about" context, even if every reply has
-- been paginated past.
--
-- Reply selection happens DESC (newest replies first) so the cursor walks
-- toward older replies; the outer SELECT then re-sorts the combined output
-- ASC so the body stays chronological (oldest → newest), matching every
-- other comment list path.
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @anchor_id AND c.issue_id = @issue_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = @issue_id AND c.workspace_id = @workspace_id
),
reply_page AS (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id
FROM descendants d
WHERE d.id NOT IN (SELECT id FROM thread_root)
AND (
@has_cursor::boolean = FALSE
OR (d.created_at, d.id) < (@before_at::timestamptz, @before_id::uuid)
)
ORDER BY d.created_at DESC, d.id DESC
LIMIT @reply_limit
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id
FROM descendants d
JOIN thread_root tr ON d.id = tr.id
UNION ALL
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM reply_page
) combined
ORDER BY created_at ASC, id ASC;
-- name: ListRecentThreadCommentsForIssue :many
-- Returns the N most recently active threads (root + every descendant) rather
-- than the N most recent rows. A thread's "last activity" is MAX(created_at)
-- over its whole subtree; threads are ranked by (last_activity_at DESC,
-- root_id DESC) and the top N are expanded.
--
-- Why thread-grouped instead of row-recent: with row-recent the newest 20
-- comments can come from 8 different threads — the agent sees 8 unrelated
-- tails. With thread-grouped the agent sees N complete conversational arcs,
-- which matches how a human reads an issue (#2340).
--
-- Response ordering:
-- threads: (thread_last_activity_at ASC, root_id ASC)
-- in-thread: (created_at ASC, id ASC)
-- So the oldest-active thread appears first and the most recently-active
-- thread is at the tail, closest to "now" in an agent prompt.
--
-- Cursor scrolls back through threads. When @has_cursor=TRUE only threads
-- with (last_activity_at, root_id) < (@before_at, @before_id) are eligible.
-- The cursor is a THREAD cursor — both values identify a thread (its last
-- activity timestamp and its root comment id), not a single row.
--
-- The recursive `membership` CTE labels each comment with its thread root by
-- walking down from every root. It does not assume any maximum nesting depth,
-- which preserves correctness even if the schema ever allows reply-of-reply
-- (the agent path in TaskService.createAgentComment collapses to root today,
-- but the user-facing CreateComment handler does not enforce it).
WITH RECURSIVE membership(id, root_id, comment_created_at) AS (
-- Each root maps to itself.
SELECT c.id, c.id AS root_id, c.created_at
FROM comment c
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
AND c.parent_id IS NULL
UNION ALL
-- Each descendant inherits its parent's root_id.
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = @issue_id
AND c.workspace_id = @workspace_id
),
thread_stats AS (
SELECT root_id, MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
),
picked AS (
SELECT ts.root_id, ts.last_activity_at
FROM thread_stats ts
WHERE (
@has_cursor::boolean = FALSE
OR (ts.last_activity_at, ts.root_id) < (@before_at::timestamptz, @before_id::uuid)
)
ORDER BY ts.last_activity_at DESC, ts.root_id DESC
LIMIT @thread_limit
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
p.root_id AS thread_root_id,
p.last_activity_at AS thread_last_activity_at
FROM picked p
JOIN membership m ON m.root_id = p.root_id
JOIN comment c ON c.id = m.id
ORDER BY p.last_activity_at ASC, p.root_id ASC, c.created_at ASC, c.id ASC;
-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2;
-- name: CountNewCommentsSince :one
-- Counts comments on an issue created strictly after @since, ACROSS THE WHOLE
-- ISSUE (every thread, not just the triggering one). Excludes the triggering
-- comment itself (@anchor_id — its body is already injected into the prompt)
-- and any authored by the given agent (@author_id), so a chatty agent does not
-- inflate its own new-comment count. The agent is steered to read the
-- triggering thread first (see BuildNewCommentsHint), but the count is
-- issue-wide so it knows the full catch-up volume. Feeds the daemon claim
-- response without shipping comment bodies.
SELECT count(*) FROM comment
WHERE issue_id = @issue_id
AND workspace_id = @workspace_id
AND created_at > @since
AND id <> @anchor_id
AND NOT (author_type = 'agent' AND author_id = @author_id);
-- name: GetLatestMemberCommentForIssueSince :one
-- MUL-4195 completion reconciliation: the newest MEMBER-authored comment on an
-- issue created strictly after @since (a run's started_at). Used when a task
-- completes to detect deliberate user input that landed while the agent was
-- busy — or that was merged into the running task after its context was
-- already built — so a single follow-up run can be scheduled for it. Restricted
-- to author_type = 'member' on purpose: only human input earns the guaranteed
-- follow-up, which preserves the existing anti-loop guarantees (agent replies,
-- acknowledgements, and self-triggers never qualify). Returns pgx.ErrNoRows
-- when nothing newer exists, i.e. the run already covered the latest input.
SELECT * FROM comment
WHERE issue_id = @issue_id
AND author_type = 'member'
AND created_at > @since
ORDER BY created_at DESC
LIMIT 1;
-- name: ListReconcilableCommentsForIssueSince :many
-- MUL-4195 / MUL-4304 completion reconciliation: every MEMBER- or AGENT-authored
-- comment on an issue created strictly after @since (the completing run's
-- created_at anchor), plus every id in its planned trigger/coalesced batch.
-- Planned ids matter for retry children because their input comments predate
-- the child's created_at; if one could not be embedded at claim time it still
-- needs reconciliation. The handler excludes only delivered_comment_ids, then
-- replays the remainder through the normal trigger pipeline oldest first.
--
-- Author-type scope (MUL-4304): originally restricted to author_type = 'member'.
-- That left a gap — an explicit agent→agent @mention (agent A comments
-- `@agent B`) that landed while B already had a DISPATCHED task was dropped by
-- the create-time enqueue path (merge only folds into a QUEUED task, so a
-- dispatched target hits the merge-miss + active-task continue) and then never
-- compensated here, because agent-authored comments were excluded. We now also
-- return 'agent' comments so those explicit mentions can be replayed.
--
-- This does NOT reopen the anti-loop guarantees the member-only filter was
-- protecting. The reconcile pass runs each returned comment through
-- computeCommentAgentTriggers under its OWN author_type, and for an agent author
-- it then keeps ONLY explicit @agent/@squad mention triggers
-- (keepExplicitMentionTriggers) — the assigned-squad-leader fallback and all
-- other conversational routing are dropped, so a plain agent reply /
-- acknowledgement yields nothing regardless of issue assignment. The reconcile
-- pass further keeps only triggers routing to the agent that just completed, so
-- an agent comment can never fan out to an unrelated agent. Ordered ASC so
-- replaying in order lets later comments coalesce onto the follow-up created by
-- the first.
SELECT * FROM comment
WHERE issue_id = @issue_id
AND author_type IN ('member', 'agent')
AND (
created_at > @since
OR id = ANY(@planned_comment_ids::uuid[])
)
ORDER BY created_at ASC, id ASC;
-- name: GetComment :one
SELECT * FROM comment
WHERE id = $1;
-- name: GetCommentInWorkspace :one
SELECT * FROM comment
WHERE id = $1 AND workspace_id = $2;
-- name: GetThreadRoot :one
-- Returns the thread-root comment for @comment_id by walking parent_id up to
-- the row whose parent_id IS NULL. For a root comment it returns that comment
-- itself. Used when callers need thread-level behavior while parent_id remains
-- the exact direct parent of a reply. Cycle-safe under the PK constraint (a
-- comment cannot be its own ancestor).
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @comment_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
)
SELECT c.* FROM comment c
WHERE c.id = (SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1);
-- name: CreateComment :one
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, source_task_id)
VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(parent_id), sqlc.narg(source_task_id))
RETURNING *;
-- name: UpdateComment :one
UPDATE comment SET
content = $2,
source_task_id = sqlc.narg(source_task_id),
updated_at = now()
WHERE id = $1
RETURNING *;
-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = @issue_id
AND author_type = 'agent'
AND author_id = @author_id
AND created_at >= @since
) AS commented;
-- name: HasAgentRepliedInThread :one
-- Returns true if the given agent has posted a reply in the thread rooted at
-- the specified parent comment. Used to detect agent participation in a
-- member-started thread so that follow-up member replies still trigger the agent.
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = @parent_id AND author_type = 'agent' AND author_id = @agent_id;
-- name: DeleteComment :exec
-- Defense-in-depth: workspace_id is a SQL-layer tenant guard. See DeleteIssue.
DELETE FROM comment WHERE id = $1 AND workspace_id = $2;
-- name: ResolveComment :one
-- Idempotent: re-resolving keeps the original resolved_at + resolver. Always
-- returns the row so the handler can surface the canonical state.
UPDATE comment SET
resolved_at = COALESCE(resolved_at, now()),
resolved_by_type = COALESCE(resolved_by_type, $2),
resolved_by_id = COALESCE(resolved_by_id, $3),
updated_at = CASE WHEN resolved_at IS NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING *;
-- name: ClearOtherThreadResolutions :many
-- Single-resolution invariant: a thread has at most one resolved comment.
-- Resolving @target_id makes it the sole resolution, so this clears resolved_at
-- on every OTHER currently-resolved comment in the same thread (the root of
-- @target_id plus every descendant). The handler runs this in the SAME tx as
-- ResolveComment so the replace is atomic — a crash can never leave two
-- resolutions or zero. Scope is the thread only (id IN descendants AND
-- id <> @target_id), never the whole issue. Returns each cleared row so the
-- handler can emit a comment:unresolved event per row; granular realtime
-- consumers replace a single comment in place and would otherwise keep
-- displaying the stale resolution.
WITH RECURSIVE root_of AS (
-- Walk up from the target to its thread root.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = @target_id AND c.issue_id = @issue_id AND c.workspace_id = @workspace_id
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Expand back down from the root over the whole subtree. Cycle-safe under
-- the PK constraint (a comment cannot be its own ancestor).
SELECT c.id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = @issue_id AND c.workspace_id = @workspace_id
)
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = now()
WHERE comment.id IN (SELECT id FROM descendants)
AND comment.id <> @target_id
AND comment.resolved_at IS NOT NULL
RETURNING *;
-- name: UnresolveComment :one
-- Idempotent: a no-op clear (already unresolved) just returns the row.
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING *;