package app

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"math"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/core/domain"
	"github.com/tidbcloud/AgentTeam/internal/core/routing"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/coordinator"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/team"
)

// AppMessage is the message shape expected by the web app client.

const (
	MessageTypeSystem           = message.MessageTypeSystem
	MessageTypeOwnerQuote       = message.MessageTypeOwnerQuote
	MessageTypeChannelShareCard = message.MessageTypeChannelShareCard
	MessageTypeThreadShareCard  = message.MessageTypeThreadShareCard
	MessageTypeSticker          = message.MessageTypeSticker
	defaultSyncMessagesLimit    = 200
	maxSyncMessagesLimit        = 500
)

type SendAppMessageOptions struct {
	MessageType         string
	ExplicitRecipients  []message.AgentRecipient
	ExplicitWakeTargets []message.AgentRecipient
	Metadata            map[string]any
	// AllowWorkflowContextMetadata is set only by trusted workflow effect
	// writers. Public Human/message facades must leave it false.
	AllowWorkflowContextMetadata bool
	// RequireSingleWorkflowCallbackResponder is set only by the first-class
	// Workflow Run effect writer. It is deliberately absent from public DTOs.
	RequireSingleWorkflowCallbackResponder bool
	// WorkflowCallbackResponderAgentRef is trusted compiler/runtime data. When
	// present, callback routing resolves this selected AgentRef in the destination
	// scope and materializes it as an ordinary visible resolved mention.
	WorkflowCallbackResponderAgentRef string
	// WorkflowRunTeamID is the frozen server-authorized Team of a Run with a
	// frozen Team ID (Team-linked binding or dynamic-explicit teamId). When set
	// together with a callback responder, the message transaction verifies the
	// resolved responder is an active member of that Team BEFORE persisting or
	// fanning out any message. A rejected member rolls the transaction back
	// with zero side effects.
	WorkflowRunTeamID string
	// WorkflowCallbackExpectedResponderAgentID is an internal-only trusted fence
	// set by the Workflow Run effect dispatcher after the responder is frozen. The
	// message transaction compares it against the canonically resolved responder
	// and rolls back (zero visible side effects) on mismatch, so a drifted
	// alias/visibility can never write under a different Agent while holding the
	// frozen Agent's queue/lease. It is deliberately absent from public DTOs.
	WorkflowCallbackExpectedResponderAgentID string
	WorkflowCallbackTaskWatchHandoff         *message.WorkflowCallbackTaskWatchHandoff
	Source                                   message.ContributionSource
	MentionHints                             []message.MessageMentionHint
	StartAgentThread                         bool
	RoutingPrincipalMemberID                 string
	ClientMessageID                          string
	ExpectedAuthorMemberID                   string
	InterruptPolicy                          string
	SkipContentModeration                    bool
	OwnerQuoteSourceMessageIDs               []string
	// suppressAgentDelivery is reserved for the Desktop onboarding mock
	// exchange. It preserves validated mention metadata and ordinary browser
	// projection while producing no durable or wake Agent recipients.
	suppressAgentDelivery bool
	// skipWorkflowCallbackRouting is used only by the callback idempotency
	// replay preflight. Replays must authenticate the frozen Human and compare
	// immutable payload bytes without re-resolving mutable Agent routing.
	skipWorkflowCallbackRouting bool
}

func (store *Store) ListMessagesForUser(ctx context.Context, userID string, workspaceID string, channelID string, limit int, before *int64, after *int64) (message.AppMessagePage, error) {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelReadAction, dmReadAction, threadReadAction)
	if err != nil {
		return message.AppMessagePage{}, err
	}

	ownerContextDismissedThroughGlobalSeq, err := loadThreadOwnerContextDismissedThroughGlobalSeq(ctx, store.db, membership.Member.ID, appChannelIDForTarget(target))
	if err != nil {
		return message.AppMessagePage{}, err
	}

	readLimit := limit + 1
	var messages []message.AppMessage
	if after != nil {
		messages, err = store.readMessagesByThreadGlobalSeqAfter(ctx, workspaceID, target.ThreadID, channelID, membership.Member.ID, readLimit, *after)
	} else {
		messages, err = store.readMessagesByThreadGlobalSeq(ctx, workspaceID, target.ThreadID, channelID, membership.Member.ID, readLimit, before)
	}
	if err != nil {
		return message.AppMessagePage{}, err
	}
	messages, hasMore, nextCursor := trimAppMessagePage(messages, limit, after != nil)
	replyThreadSummaries, err := loadReplyThreadSummariesForMessagePage(ctx, store.db, workspaceID, target, messages)
	if err != nil {
		return message.AppMessagePage{}, err
	}
	var participationStats *message.MessageParticipationStats
	if target.ReplyThread != nil && before == nil && after == nil {
		stats, err := loadMessageParticipationStats(ctx, store.db, workspaceID, target.ChannelID, membership.Member.ID)
		if err != nil {
			return message.AppMessagePage{}, err
		}
		participationStats = &stats
	}
	return message.AppMessagePage{
		Messages:                              messages,
		ReplyThreadSummaries:                  replyThreadSummaries,
		ParticipationStats:                    participationStats,
		HasMore:                               hasMore,
		NextCursor:                            nextCursor,
		OwnerContextDismissedThroughGlobalSeq: ownerContextDismissedThroughGlobalSeq,
	}, nil
}

func trimAppMessagePage(messages []message.AppMessage, limit int, newer bool) ([]message.AppMessage, bool, int64) {
	if limit <= 0 || len(messages) <= limit {
		return messages, false, 0
	}
	if newer {
		messages = messages[:limit]
		nextCursor := int64(0)
		if len(messages) > 0 {
			nextCursor = messages[len(messages)-1].Seq
		}
		return messages, true, nextCursor
	}
	messages = messages[len(messages)-limit:]
	nextCursor := int64(0)
	if len(messages) > 0 {
		nextCursor = messages[0].Seq
	}
	return messages, true, nextCursor
}

func loadReplyThreadSummariesForMessagePage(ctx context.Context, queryer coredb.Queryer, workspaceID string, target message.ThreadAccess, pageMessages []message.AppMessage) (map[string]message.ReplyThreadSummary, error) {
	if len(pageMessages) == 0 || target.ReplyThread != nil {
		return nil, nil
	}
	if target.ThreadType != domain.ThreadTypeChannel && target.ThreadType != domain.ThreadTypeDM {
		return nil, nil
	}

	summaries, err := message.ListReplyThreadSummariesForParentMessageIDs(ctx, queryer, workspaceID, target.ThreadID, collectAppMessageIDs(pageMessages))
	if err != nil {
		return nil, err
	}
	if len(summaries) == 0 {
		return nil, nil
	}
	return summaries, nil
}

func loadMessageParticipationStats(ctx context.Context, queryer coredb.Queryer, workspaceID string, scopeID string, _ string) (message.MessageParticipationStats, error) {
	var stats message.MessageParticipationStats
	excludedMessageTypes := message.ParticipationExcludedMessageTypes()
	args := []any{workspaceID, scopeID}
	for _, messageType := range excludedMessageTypes {
		args = append(args, messageType)
	}
	messageTypePredicate := ""
	if len(excludedMessageTypes) > 0 {
		messageTypePredicate = fmt.Sprintf("\n  AND message_type NOT IN (%s)", appSQLDollarParamList(3, len(excludedMessageTypes)))
	}
	if err := queryer.QueryRow(ctx, fmt.Sprintf(`
SELECT
  COUNT(*),
  COALESCE(SUM(CASE WHEN author_type = 'agent' THEN 1 ELSE 0 END), 0),
  COALESCE(SUM(CASE WHEN author_type = 'human' THEN 1 ELSE 0 END), 0)
FROM scope_messages
WHERE workspace_id = $1
  AND scope_id = $2
  AND author_type IN ('agent', 'human')
  AND NOT EXISTS (
    SELECT 1 FROM workflow_run_effects
    WHERE workspace_id = scope_messages.workspace_id
      AND message_id = scope_messages.id
  )
  AND NOT EXISTS (
    SELECT 1 FROM automation_runs
    WHERE workspace_id = scope_messages.workspace_id
      AND message_id = scope_messages.id
  )
  AND NOT EXISTS (
    SELECT 1 FROM service_event_deliveries
    WHERE workspace_id = scope_messages.workspace_id
      AND message_id = scope_messages.id
  )
  AND `+message.MessageNotRewoundSQL("scope_messages")+`%s
`, messageTypePredicate), args...).Scan(&stats.Total, &stats.Agent, &stats.Human); err != nil {
		return message.MessageParticipationStats{}, fmt.Errorf("load message participation stats: %w", err)
	}
	return stats, nil
}

func appSQLDollarParamList(start int, count int) string {
	params := make([]string, 0, count)
	for i := 0; i < count; i++ {
		params = append(params, fmt.Sprintf("$%d", start+i))
	}
	return strings.Join(params, ", ")
}

func (store *Store) ListMessagesAroundForUser(ctx context.Context, userID string, workspaceID string, channelID string, aroundMessageID string, limit int) (message.AppMessagePage, error) {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelReadAction, dmReadAction, threadReadAction)
	if err != nil {
		return message.AppMessagePage{}, err
	}

	ownerContextDismissedThroughGlobalSeq, err := loadThreadOwnerContextDismissedThroughGlobalSeq(ctx, store.db, membership.Member.ID, appChannelIDForTarget(target))
	if err != nil {
		return message.AppMessagePage{}, err
	}

	if limit <= 0 {
		return message.AppMessagePage{}, badRequest("invalid limit")
	}
	halfLimit := limit / 2
	if halfLimit < 1 {
		halfLimit = 1
	}

	rows, err := store.db.Query(ctx, fmt.Sprintf(`
WITH target_msg AS (
    SELECT seq
    FROM scope_messages
    WHERE id = $4 AND scope_id = $1
      AND %s
    LIMIT 1
),
before_page AS (
    SELECT id, seq, author_member_id, author_type, client_message_id, message_type, content, metadata, created_at,
           edited_at, edited_by_member_id, edit_revision
    FROM scope_messages
    WHERE scope_id = $1
      AND seq <= (SELECT seq FROM target_msg)
      AND %s
    ORDER BY seq DESC
    LIMIT $2
),
after_page AS (
    SELECT id, seq, author_member_id, author_type, client_message_id, message_type, content, metadata, created_at,
           edited_at, edited_by_member_id, edit_revision
    FROM scope_messages
    WHERE scope_id = $1
      AND seq > (SELECT seq FROM target_msg)
      AND %s
    ORDER BY seq ASC
    LIMIT $3
),
page AS (
    SELECT id, seq, author_member_id, author_type, client_message_id, message_type, content, metadata, created_at,
           edited_at, edited_by_member_id, edit_revision
    FROM before_page
    UNION ALL
    SELECT id, seq, author_member_id, author_type, client_message_id, message_type, content, metadata, created_at,
           edited_at, edited_by_member_id, edit_revision
    FROM after_page
)
SELECT
    page.id,
    page.seq,
    $1 AS channel_id,
    page.author_type,
    author_member.id,
    u.id,
    a.id,
    a.name,
    u.display_name,
    author_member.display_name,
    u.name,
    u.avatar_url,
    a.avatar_url,
    page.client_message_id,
    page.message_type,
    page.content,
    page.metadata,
    page.created_at,
    page.edited_at,
    COALESCE(page.edited_by_member_id, ''),
    page.edit_revision
FROM page
JOIN members author_member ON author_member.id = page.author_member_id
LEFT JOIN users u ON page.author_type = 'human' AND u.id = author_member.user_id
LEFT JOIN agents a ON page.author_type = 'agent' AND a.member_id = author_member.id
ORDER BY page.seq ASC
`,
		humanVisibleMessageTypeSQL("scope_messages"),
		humanVisibleMessageTypeSQL("scope_messages"),
		humanVisibleMessageTypeSQL("scope_messages"),
	), channelID, halfLimit, halfLimit, strings.TrimSpace(aroundMessageID))
	if err != nil {
		return message.AppMessagePage{}, fmt.Errorf("load messages around: %w", err)
	}
	defer rows.Close()

	messages := make([]message.AppMessage, 0)
	for rows.Next() {
		appMessage, err := scanAppMessageRow(rows)
		if err != nil {
			return message.AppMessagePage{}, err
		}
		messages = append(messages, appMessage)
	}
	if err := rows.Err(); err != nil {
		return message.AppMessagePage{}, fmt.Errorf("iterate messages around: %w", err)
	}

	attachmentLookup, err := store.loadAttachmentsForMessages(ctx, workspaceID, collectAppMessageIDs(messages))
	if err != nil {
		return message.AppMessagePage{}, err
	}
	for i := range messages {
		messages[i].Attachments = attachmentLookup[messages[i].ID]
	}
	if err := store.attachAppMessageReadDecorations(ctx, workspaceID, membership.Member.ID, messages); err != nil {
		return message.AppMessagePage{}, err
	}
	replyThreadSummaries, err := loadReplyThreadSummariesForMessagePage(ctx, store.db, workspaceID, target, messages)
	if err != nil {
		return message.AppMessagePage{}, err
	}
	hasMore, nextCursor, err := hasOlderAppMessages(ctx, store.db, channelID, messages)
	if err != nil {
		return message.AppMessagePage{}, err
	}
	return message.AppMessagePage{
		Messages:                              messages,
		ReplyThreadSummaries:                  replyThreadSummaries,
		HasMore:                               hasMore,
		NextCursor:                            nextCursor,
		OwnerContextDismissedThroughGlobalSeq: ownerContextDismissedThroughGlobalSeq,
	}, nil
}

func hasOlderAppMessages(ctx context.Context, queryer coredb.QueryRower, channelID string, messages []message.AppMessage) (bool, int64, error) {
	if len(messages) == 0 {
		return false, 0, nil
	}
	oldestSeq := messages[0].Seq
	var hasMore bool
	if err := queryer.QueryRow(ctx, fmt.Sprintf(`
SELECT EXISTS (
    SELECT 1
    FROM scope_messages
    WHERE scope_id = $1
      AND seq < $2
      AND %s
)
`, humanVisibleMessageTypeSQL("scope_messages")), channelID, oldestSeq).Scan(&hasMore); err != nil {
		return false, 0, fmt.Errorf("check older messages: %w", err)
	}
	if !hasMore {
		return false, 0, nil
	}
	return true, oldestSeq, nil
}

func (store *Store) attachAppMessageReadDecorations(ctx context.Context, workspaceID string, viewerMemberID string, messages []message.AppMessage) error {
	if err := message.RedactServiceAccountPublisherMetadataForViewer(ctx, store.db, workspaceID, viewerMemberID, messages); err != nil {
		return err
	}
	if err := attachServiceAuthorProfilePresentationWithQueryer(ctx, store.db, workspaceID, messages); err != nil {
		return err
	}
	if err := message.AttachContributionSourcesForAppMessages(ctx, store.db, workspaceID, messages); err != nil {
		return err
	}
	if err := message.AttachResolvedMentionsForAppMessages(ctx, store.db, workspaceID, viewerMemberID, messages); err != nil {
		return err
	}
	if err := message.AttachMessagePinStateForAppMessages(ctx, store.db, workspaceID, viewerMemberID, messages); err != nil {
		return err
	}
	if err := attachAppNotificationCardSummaries(ctx, store, messages); err != nil {
		return err
	}
	return store.Reaction().AttachAppMessageReactionAggregates(ctx, workspaceID, viewerMemberID, messages)
}

// attachAppNotificationCardSummaries batch-loads the interactive-card render
// payloads for every app_interactive_card message on the page and stamps each
// message with the raw RenderPayloadJSON bytes. A single batched SELECT
// replaces the per-card fetch so a full page costs one query regardless of how
// many cards it carries. The store carries raw bytes only; the scrub that
// extracts client-safe keys happens at the API/DTO converter boundary.
// Messages without a card row keep a nil AppNotificationCard.
func attachAppNotificationCardSummaries(ctx context.Context, store *Store, messages []message.AppMessage) error {
	if len(messages) == 0 {
		return nil
	}
	messageIDs := make([]string, 0, len(messages))
	messageIndexByMessageID := make(map[string]int, len(messages))
	for index := range messages {
		if strings.TrimSpace(messages[index].MessageType) != message.MessageTypeAppInteractiveCard {
			continue
		}
		messageID := strings.TrimSpace(messages[index].ID)
		if messageID == "" {
			continue
		}
		if _, exists := messageIndexByMessageID[messageID]; exists {
			continue
		}
		messageIndexByMessageID[messageID] = index
		messageIDs = append(messageIDs, messageID)
	}
	if len(messageIDs) == 0 {
		return nil
	}
	cards, err := store.LoadAppNotificationCardsByMessageIDs(ctx, messageIDs)
	if err != nil {
		return err
	}
	for messageID, index := range messageIndexByMessageID {
		record, ok := cards[messageID]
		if !ok {
			continue
		}
		messages[index].AppNotificationCard = record.RenderPayloadJSON
	}
	return nil
}

func (store *Store) DecorateAppMessageForUser(ctx context.Context, userID string, workspaceID string, appMessage message.AppMessage) (message.AppMessage, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return message.AppMessage{}, err
	}
	messages := []message.AppMessage{appMessage}
	if err := store.attachAppMessageReadDecorations(ctx, workspaceID, membership.Member.ID, messages); err != nil {
		return message.AppMessage{}, err
	}
	return messages[0], nil
}

func (store *Store) DecorateAppMessageResolvedMentionsForMembers(ctx context.Context, workspaceID string, appMessage message.AppMessage, viewerMemberIDs []string) (map[string]message.AppMessage, error) {
	baseMessage := appMessage
	baseMessage.ResolvedMentions = nil
	decoratedByMemberID := make(map[string]message.AppMessage, len(viewerMemberIDs))
	normalizedMemberIDs := make([]string, 0, len(viewerMemberIDs))
	seenMemberIDs := make(map[string]struct{}, len(viewerMemberIDs))
	for _, viewerMemberID := range viewerMemberIDs {
		trimmed := strings.TrimSpace(viewerMemberID)
		if trimmed == "" {
			continue
		}
		if _, ok := seenMemberIDs[trimmed]; ok {
			continue
		}
		seenMemberIDs[trimmed] = struct{}{}
		normalizedMemberIDs = append(normalizedMemberIDs, trimmed)
		decoratedByMemberID[trimmed] = baseMessage
	}
	if len(normalizedMemberIDs) == 0 {
		return decoratedByMemberID, nil
	}

	resolvedByMemberID, err := message.ResolveAppMessageMentionsForViewers(ctx, store.db, workspaceID, baseMessage, normalizedMemberIDs)
	if err != nil {
		return nil, err
	}
	for memberID, resolvedMentions := range resolvedByMemberID {
		decorated := decoratedByMemberID[memberID]
		decorated.ResolvedMentions = resolvedMentions
		decoratedByMemberID[memberID] = decorated
	}
	return decoratedByMemberID, nil
}

func (store *Store) LookupMessageChannelID(ctx context.Context, workspaceID string, messageID string) (string, error) {
	channelID, _, err := store.LookupMessageChannel(ctx, workspaceID, messageID)
	return channelID, err
}

func (store *Store) LookupMessageChannel(ctx context.Context, workspaceID string, messageID string) (string, string, error) {
	var channelID string
	var channelType string
	err := store.db.QueryRow(ctx, `
SELECT sm.scope_id, scope.type
FROM scope_messages sm
JOIN scopes scope ON scope.id = sm.scope_id
WHERE sm.id = $1
  AND sm.workspace_id = $2
  AND scope.type IN ('channel', 'thread', 'dm', 'service_account')
`, strings.TrimSpace(messageID), strings.TrimSpace(workspaceID)).Scan(&channelID, &channelType)
	if err != nil {
		if coredb.IsNotFound(err) {
			return "", "", nil
		}
		return "", "", fmt.Errorf("lookup message channel: %w", err)
	}
	return strings.TrimSpace(channelID), strings.TrimSpace(channelType), nil
}

func (store *Store) UpdateChannelReadStateByGlobalSeq(ctx context.Context, userID string, workspaceID string, channelID string, lastReadGlobalSeq int64) (int64, error) {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelReadAction, dmReadAction, threadReadAction)
	if err != nil {
		return 0, err
	}

	if lastReadGlobalSeq < 0 {
		return 0, badRequest("invalid sequence")
	}

	scopeID := appChannelIDForTarget(target)
	row := store.db.QueryRow(ctx, `
SELECT latest_seq
FROM scopes
WHERE id = $1
`, scopeID)
	var latestSeq int64
	if err := row.Scan(&latestSeq); err != nil {
		if coredb.IsNotFound(err) {
			return 0, ErrNotFound
		}
		return 0, fmt.Errorf("load scope latest seq: %w", err)
	}

	actualThreadSeq := lastReadGlobalSeq
	if actualThreadSeq > latestSeq {
		actualThreadSeq = latestSeq
	}

	updatedAt := time.Now().UTC()
	if err := writeThreadReadStateByGlobalSeqWithExecer(ctx, store.db, membership.Member.ID, scopeID, actualThreadSeq, actualThreadSeq, updatedAt); err != nil {
		return 0, fmt.Errorf("update read state: %w", err)
	}
	if err := writeScopeParticipantReadStateForTargetWithExecer(ctx, store.db, target, membership.Member.ID, actualThreadSeq, actualThreadSeq, updatedAt, false); err != nil {
		return 0, err
	}
	if err := message.ClearConversationManualUnreadState(ctx, store.db, membership.Member.ID, scopeID, updatedAt); err != nil {
		return 0, err
	}
	return actualThreadSeq, nil
}

func (store *Store) MarkChannelReadAllForUser(ctx context.Context, userID string, workspaceID string, channelID string) (int64, error) {
	const maxInt64 = int64(^uint64(0) >> 1)
	return store.UpdateChannelReadStateByGlobalSeq(ctx, userID, workspaceID, channelID, maxInt64)
}

func (store *Store) DismissPendingOwnerContextForUser(ctx context.Context, userID string, workspaceID string, channelID string, dismissedThroughGlobalSeq int64) error {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelReadAction, dmReadAction, threadReadAction)
	if err != nil {
		return err
	}

	if dismissedThroughGlobalSeq < 0 {
		return badRequest("invalid dismissedThroughGlobalSeq")
	}
	scopeID := appChannelIDForTarget(target)
	row := store.db.QueryRow(ctx, `
SELECT latest_seq
FROM scopes
WHERE id = $1
`, scopeID)
	var latestSeq int64
	if err := row.Scan(&latestSeq); err != nil {
		if coredb.IsNotFound(err) {
			return ErrNotFound
		}
		return fmt.Errorf("load scope latest seq: %w", err)
	}
	if latestSeq < 0 || dismissedThroughGlobalSeq > latestSeq {
		return badRequest("invalid dismissedThroughGlobalSeq")
	}

	if err := writeThreadOwnerContextDismissStateWithExecer(ctx, store.db, membership.Member.ID, scopeID, dismissedThroughGlobalSeq); err != nil {
		return fmt.Errorf("dismiss pending owner context: %w", err)
	}
	if _, err := store.db.Exec(ctx, `
UPDATE scope_memberships
SET owner_context_dismissed_through_global_seq = GREATEST(owner_context_dismissed_through_global_seq, $3),
    updated_at = $4
WHERE member_id = $1 AND scope_id = $2 AND removed_at IS NULL AND effective_to_seq IS NULL
`, membership.Member.ID, scopeID, dismissedThroughGlobalSeq, time.Now().UTC()); err != nil {
		return fmt.Errorf("dismiss pending owner context scope membership: %w", err)
	}
	return nil
}

func (store *Store) MarkChannelUnreadForUser(ctx context.Context, userID string, workspaceID string, channelID string) (int64, error) {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelReadAction, dmReadAction, threadReadAction)
	if err != nil {
		return 0, err
	}

	latestRow := store.db.QueryRow(ctx, `
SELECT COALESCE(MAX(seq), 0)
FROM scope_messages
WHERE scope_id = $1
  AND `+message.HumanVisibleMessageTypeSQL("scope_messages")+`
`, appChannelIDForTarget(target))
	var latestVisibleSeq int64
	if err := latestRow.Scan(&latestVisibleSeq); err != nil {
		return 0, fmt.Errorf("load latest visible scope seq: %w", err)
	}
	if latestVisibleSeq < 0 {
		return 0, fmt.Errorf("invalid latest visible scope seq")
	}
	if latestVisibleSeq <= 0 {
		return 0, nil
	}
	if err := message.SetConversationManualUnreadState(ctx, store.db, membership.Member.ID, appChannelIDForTarget(target), latestVisibleSeq, time.Now().UTC()); err != nil {
		return 0, err
	}
	return 1, nil
}

func (store *Store) UnreadCountsForUser(ctx context.Context, userID string, workspaceID string) (map[string]int64, error) {
	membership, err := store.requireWorkspaceAccess(ctx, userID, workspaceID)
	if err != nil {
		return nil, err
	}

	query, args, err := message.BuildUnreadCountsForUserScopedLocalSeqQuery(workspaceID, membership.Member.ID, userID)
	if err != nil {
		return nil, err
	}

	rows, err := store.db.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("load unread counts: %w", err)
	}
	defer rows.Close()

	counts := make(map[string]int64)
	for rows.Next() {
		var channelID string
		var unread int64
		if err := rows.Scan(&channelID, &unread); err != nil {
			return nil, fmt.Errorf("scan unread count: %w", err)
		}
		if channelID == "" {
			continue
		}
		if unread > 0 {
			counts[channelID] = unread
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate unread counts: %w", err)
	}
	return counts, nil
}

func (store *Store) HasUnreadForUserWorkspaces(ctx context.Context, userID string, workspaceIDs []string) (map[string]bool, error) {
	query, args, err := message.BuildWorkspaceHasUnreadForUserScopedQuery(userID, workspaceIDs)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(query) == "" {
		return map[string]bool{}, nil
	}

	rows, err := store.db.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("load workspace unread existence: %w", err)
	}
	defer rows.Close()

	result := make(map[string]bool)
	for rows.Next() {
		var workspaceID string
		if err := rows.Scan(&workspaceID); err != nil {
			return nil, fmt.Errorf("scan workspace unread existence: %w", err)
		}
		workspaceID = strings.TrimSpace(workspaceID)
		if workspaceID != "" {
			result[workspaceID] = true
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate workspace unread existence: %w", err)
	}
	return result, nil
}

func (store *Store) SendMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string) (message.AppMessage, message.MessageDelivery, error) {
	return store.SendMessageForUserWithType(ctx, user, workspaceID, channelID, content, attachmentIDs, "")
}

func (store *Store) SendMessageForUserWithOptions(ctx context.Context, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string, options message.HumanMessageCreateOptions) (message.AppMessage, message.MessageDelivery, error) {
	return store.sendMessageForUserTargetAware(ctx, user, workspaceID, channelID, content, attachmentIDs, SendAppMessageOptions{
		MessageType:                              options.MessageType,
		Metadata:                                 options.Metadata,
		AllowWorkflowContextMetadata:             options.AllowWorkflowContextMetadata,
		RequireSingleWorkflowCallbackResponder:   options.RequireSingleWorkflowCallbackResponder,
		WorkflowCallbackResponderAgentRef:        options.WorkflowCallbackResponderAgentRef,
		WorkflowRunTeamID:                        options.WorkflowRunTeamID,
		WorkflowCallbackExpectedResponderAgentID: options.WorkflowCallbackExpectedResponderAgentID,
		WorkflowCallbackTaskWatchHandoff:         options.WorkflowCallbackTaskWatchHandoff,
		Source:                                   options.Source,
		MentionHints:                             options.MentionHints,
		ClientMessageID:                          options.ClientMessageID,
		suppressAgentDelivery:                    options.SuppressAgentDelivery,
		ExpectedAuthorMemberID:                   options.ExpectedAuthorMemberID,
		StartAgentThread:                         options.StartAgentThread,
		InterruptPolicy:                          options.InterruptPolicy,
		OwnerQuoteSourceMessageIDs:               options.OwnerQuoteSourceMessageIDs,
	})
}

func (store *Store) SendMessageForUserWithMetadata(ctx context.Context, user model.User, workspaceID string, scopeID string, content string, attachmentIDs []string, metadata map[string]any) (message.AppMessage, message.MessageDelivery, error) {
	return store.sendMessageForUserTargetAware(ctx, user, workspaceID, scopeID, content, attachmentIDs, SendAppMessageOptions{
		Metadata: metadata,
	})
}

func (store *Store) SendMessageForUserWithType(ctx context.Context, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string, messageType string) (message.AppMessage, message.MessageDelivery, error) {
	return store.sendMessageForUserTargetAware(ctx, user, workspaceID, channelID, content, attachmentIDs, SendAppMessageOptions{
		MessageType: messageType,
	})
}

func (store *Store) ValidateMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string) error {
	return store.validateMessageForUserTargetAware(ctx, user, workspaceID, channelID, content, SendAppMessageOptions{}, false)
}

func (store *Store) ValidateAndAuditMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string, options message.HumanMessageCreateOptions) error {
	return store.validateMessageForUserTargetAware(ctx, user, workspaceID, channelID, content, SendAppMessageOptions{
		MessageType:                              options.MessageType,
		Metadata:                                 options.Metadata,
		AllowWorkflowContextMetadata:             options.AllowWorkflowContextMetadata,
		RequireSingleWorkflowCallbackResponder:   options.RequireSingleWorkflowCallbackResponder,
		WorkflowCallbackResponderAgentRef:        options.WorkflowCallbackResponderAgentRef,
		WorkflowRunTeamID:                        options.WorkflowRunTeamID,
		WorkflowCallbackExpectedResponderAgentID: options.WorkflowCallbackExpectedResponderAgentID,
		WorkflowCallbackTaskWatchHandoff:         options.WorkflowCallbackTaskWatchHandoff,
		Source:                                   options.Source,
		MentionHints:                             options.MentionHints,
		ClientMessageID:                          options.ClientMessageID,
		ExpectedAuthorMemberID:                   options.ExpectedAuthorMemberID,
		StartAgentThread:                         options.StartAgentThread,
		InterruptPolicy:                          options.InterruptPolicy,
		OwnerQuoteSourceMessageIDs:               options.OwnerQuoteSourceMessageIDs,
	}, true)
}

func (store *Store) validateMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string, options SendAppMessageOptions, auditModerationBlock bool) error {
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return mapMessagingError(err)
	}
	options.Source = normalizedSource

	var moderationBlock *moderationBlockRecord
	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return fmt.Errorf("begin message validation tx: %w", err)
	}
	validationErr := func(tx coredb.Tx) error {
		prepared, err := store.prepareAppMessageForUserTx(ctx, tx, user, workspaceID, channelID, content, nil, options)
		if err != nil {
			return err
		}
		block, blocked, err := store.buildModerationBlockForHumanMessage(ctx, prepared, user.ID, workspaceID, false)
		if err != nil {
			return err
		}
		if blocked {
			moderationBlock = &block
			return message.ContentModerationBlocked(contentModerationBlockedMessage)
		}
		return nil
	}(tx)
	if rollbackErr := tx.Rollback(ctx); rollbackErr != nil {
		rollbackErr = fmt.Errorf("rollback message validation tx: %w", rollbackErr)
		if validationErr != nil {
			return errors.Join(validationErr, rollbackErr)
		}
		return rollbackErr
	}
	if auditModerationBlock && errors.Is(validationErr, message.ErrContentModerationBlocked) && moderationBlock != nil {
		if insertErr := store.insertModerationBlock(ctx, store.db, *moderationBlock); insertErr != nil {
			return insertErr
		}
	}
	return validationErr
}

func (store *Store) SendSystemMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string, recipients []message.AgentRecipient, wakeTargets []message.AgentRecipient) (message.AppMessage, message.MessageDelivery, error) {
	return store.SendSystemMessageForUserWithMetadata(ctx, user, workspaceID, channelID, content, nil, recipients, wakeTargets)
}

func (store *Store) SendSystemMessageForUserWithMetadata(ctx context.Context, user model.User, workspaceID string, channelID string, content string, metadata map[string]any, recipients []message.AgentRecipient, wakeTargets []message.AgentRecipient) (message.AppMessage, message.MessageDelivery, error) {
	return store.sendAppMessageForUser(ctx, user, workspaceID, channelID, content, nil, SendAppMessageOptions{
		MessageType:           MessageTypeSystem,
		ExplicitRecipients:    recipients,
		ExplicitWakeTargets:   wakeTargets,
		Metadata:              metadata,
		SkipContentModeration: true,
	})
}

func humanVisibleMessageTypeSQL(alias string) string {
	return message.HumanVisibleMessageTypeSQL(alias)
}

func HumanVisibleMessageTypeSQL(alias string) string {
	return humanVisibleMessageTypeSQL(alias)
}

func (store *Store) SendAppMessageForUserWithOptions(ctx context.Context, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string, options SendAppMessageOptions) (message.AppMessage, message.MessageDelivery, error) {
	return store.sendAppMessageForUser(ctx, user, workspaceID, channelID, content, attachmentIDs, options)
}

type preparedUserAppMessage struct {
	membership                 access.HumanWorkspaceMember
	target                     message.ThreadAccess
	trimmedContent             string
	idempotencyContent         string
	normalizedAttachmentIDs    []string
	normalizedMessageType      string
	metadata                   map[string]any
	liveRoutingContent         string
	allowControlledBroadcast   bool
	explicitRoutingPlan        *message.AgentRoutingPlan
	routingPrincipalMemberID   string
	privateMentionRecipients   []message.AgentRecipient
	startAgentThreadRecipients []message.AgentRecipient
	workflowCallbackResponder  *message.AgentRecipient
	callbackTaskWatchHandoff   *message.WorkflowCallbackTaskWatchHandoff
}

func (store *Store) sendAppMessageForUser(ctx context.Context, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string, options SendAppMessageOptions) (message.AppMessage, message.MessageDelivery, error) {
	normalizedSource, err := options.Source.NormalizeForWrite()
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, mapMessagingError(err)
	}
	options.Source = normalizedSource

	var prepared preparedUserAppMessage
	var appMessage message.AppMessage
	var delivery message.MessageDelivery
	var replayedMessage *message.AppMessage
	var clientMessageID string
	var targetScopeID string
	var moderationBlock *moderationBlockRecord
	if err := coordinator.New(store.db).Run(ctx, "send app message workflow", func(tx coredb.Tx) error {
		var err error
		clientMessageID, err = normalizeClientMessageID(options.ClientMessageID)
		if err != nil {
			return err
		}
		if options.RequireSingleWorkflowCallbackResponder && clientMessageID != "" {
			replayOptions := options
			replayOptions.skipWorkflowCallbackRouting = true
			prepared, err = store.prepareAppMessageForUserTx(ctx, tx, user, workspaceID, channelID, content, attachmentIDs, replayOptions)
			if err != nil {
				return err
			}
			targetScopeID = message.ScopeIDForThreadAccess(prepared.target)
			replay, found, err := store.loadAppMessageReplayByClientMessageID(ctx, tx, workspaceID, targetScopeID, appChannelIDForTarget(prepared.target), prepared.membership.Member.ID, prepared.membership.Member.ID, clientMessageID)
			if err != nil {
				return err
			}
			if found {
				if err := validateClientMessageIDReplaySideEffect(replay.startedAgentThread, options); err != nil {
					return err
				}
				requestedDigest, err := appMessageCreateDigest(prepared, options)
				if err != nil {
					return err
				}
				if !strings.EqualFold(replay.idempotencyPayloadDigest, requestedDigest) {
					return conflict("clientMessageId was already used with a different message payload")
				}
				existing := replay.message
				if err := store.bindScheduleRunMessageSource(ctx, tx, workspaceID, options.Source, existing.ID, existing.CreatedAt); err != nil {
					return err
				}
				if err := store.bindWorkflowRunMessageSource(ctx, tx, workspaceID, options.Source, existing.ID, clientMessageID, existing.CreatedAt); err != nil {
					return err
				}
				if !options.Source.IsZero() {
					existing.Source = options.Source.Normalize()
				}
				// Keep replay presentation identical to the original commit: the
				// canonical automation author, never the frozen Human principal.
				message.ApplyAutomationServiceAuthorToAppMessage(&existing)
				replayedMessage = &existing
				delivery = message.MessageDelivery{
					MessageID:        existing.ID,
					GlobalSeq:        existing.Seq,
					InterruptPolicy:  strings.TrimSpace(options.InterruptPolicy),
					IdempotentReplay: true,
					AppMessage:       &existing,
				}
				return nil
			}
		}
		prepared, err = store.prepareAppMessageForUserTx(ctx, tx, user, workspaceID, channelID, content, attachmentIDs, options)
		if err != nil {
			return err
		}

		if clientMessageID != "" {
			targetScopeID = message.ScopeIDForThreadAccess(prepared.target)
			replay, found, err := store.loadAppMessageReplayByClientMessageID(ctx, tx, workspaceID, targetScopeID, appChannelIDForTarget(prepared.target), prepared.membership.Member.ID, prepared.membership.Member.ID, clientMessageID)
			if err != nil {
				return err
			}
			if found {
				if err := validateClientMessageIDReplaySideEffect(replay.startedAgentThread, options); err != nil {
					return err
				}
				requestedDigest, err := appMessageCreateDigest(prepared, options)
				if err != nil {
					return err
				}
				if !strings.EqualFold(replay.idempotencyPayloadDigest, requestedDigest) {
					return conflict("clientMessageId was already used with a different message payload")
				}
				existing := replay.message
				if err := store.bindScheduleRunMessageSource(ctx, tx, workspaceID, options.Source, existing.ID, existing.CreatedAt); err != nil {
					return err
				}
				if err := store.bindWorkflowRunMessageSource(ctx, tx, workspaceID, options.Source, existing.ID, clientMessageID, existing.CreatedAt); err != nil {
					return err
				}
				if !options.Source.IsZero() {
					existing.Source = options.Source.Normalize()
				}
				message.ApplyAutomationServiceAuthorToAppMessage(&existing)
				replayedMessage = &existing
				delivery = message.MessageDelivery{
					MessageID:        existing.ID,
					GlobalSeq:        existing.Seq,
					InterruptPolicy:  strings.TrimSpace(options.InterruptPolicy),
					IdempotentReplay: true,
					AppMessage:       &existing,
				}
				if options.StartAgentThread {
					replyThread, err := loadStartedReplyThreadReplay(ctx, tx, prepared, strings.TrimSpace(workspaceID), existing.ID)
					if err != nil {
						return err
					}
					delivery.StartedReplyThread = &replyThread
				}
				return nil
			}
		}

		block, blocked, err := store.buildModerationBlockForHumanMessage(ctx, prepared, user.ID, workspaceID, options.SkipContentModeration)
		if err != nil {
			return err
		}
		if blocked {
			moderationBlock = &block
			return message.ContentModerationBlocked(contentModerationBlockedMessage)
		}
		if prepared.workflowCallbackResponder != nil && prepared.callbackTaskWatchHandoff != nil {
			if err := store.applyWorkflowCallbackTaskWatchHandoffTx(
				ctx,
				tx,
				strings.TrimSpace(workspaceID),
				*prepared.workflowCallbackResponder,
				*prepared.callbackTaskWatchHandoff,
				time.Now().UTC(),
			); err != nil {
				return err
			}
		}
		idempotencyPayloadDigest := ""
		if options.RequireSingleWorkflowCallbackResponder && clientMessageID != "" {
			idempotencyPayloadDigest, err = appMessageCreateDigest(prepared, options)
			if err != nil {
				return err
			}
		}

		delivery, err = store.appendMessageToThread(ctx, tx, prepared.membership.Member.ID, domain.MemberTypeHuman, prepared.target, prepared.trimmedContent, prepared.normalizedAttachmentIDs, appendMessageOptions{
			AllowControlledBroadcast:     prepared.allowControlledBroadcast,
			AllowWorkflowContextMetadata: options.AllowWorkflowContextMetadata,
			MentionHints:                 options.MentionHints,
			MessageType:                  prepared.normalizedMessageType,
			ExplicitRoutingPlan:          prepared.explicitRoutingPlan,
			Metadata:                     prepared.metadata,
			Source:                       options.Source,
			RoutingPrincipalMemberID:     prepared.routingPrincipalMemberID,
			ClientMessageID:              clientMessageID,
			IdempotencyPayloadDigest:     idempotencyPayloadDigest,
			InterruptPolicy:              options.InterruptPolicy,
			OwnerQuoteSourceMessageIDs:   options.OwnerQuoteSourceMessageIDs,
		}, messageAppendCallerOwned)
		if err != nil {
			return err
		}
		if err := store.bindScheduleRunMessageSource(ctx, tx, workspaceID, options.Source, delivery.MessageID, delivery.Message.Timestamp); err != nil {
			return err
		}
		if err := store.bindWorkflowRunMessageSource(ctx, tx, workspaceID, options.Source, delivery.MessageID, clientMessageID, delivery.Message.Timestamp); err != nil {
			return err
		}

		var startedReplyThread *message.ReplyThreadSummary
		if options.StartAgentThread {
			replyThread, err := message.OpenReplyThreadForMember(ctx, tx, prepared.membership.Member.ID, strings.TrimSpace(workspaceID), strings.TrimSpace(prepared.target.ChannelID), delivery.MessageID)
			if err != nil {
				return mapMessagingError(err)
			}
			if _, err := store.ensureReplyThreadScopeProjection(ctx, tx, replyThread.ThreadChannelID); err != nil {
				return err
			}
			if err := message.ActivateThreadSubscription(ctx, tx, prepared.membership.Member.ID, replyThread.ThreadChannelID, delivery.Message.Timestamp); err != nil {
				return err
			}
			if len(prepared.startAgentThreadRecipients) > 0 {
				if err := retargetStartAgentThreadDelivery(ctx, tx, &delivery, prepared.target, replyThread, prepared.startAgentThreadRecipients); err != nil {
					return err
				}
			} else if err := activateStartedReplyThreadRecipients(ctx, tx, replyThread.ThreadChannelID, delivery.WakeRecipients, delivery.Message.Timestamp); err != nil {
				return err
			}
			updatedReplyThread, err := loadStartedReplyThreadReplay(ctx, tx, prepared, strings.TrimSpace(workspaceID), delivery.MessageID)
			if err != nil {
				return err
			}
			startedReplyThread = &updatedReplyThread
			delivery.StartedReplyThread = startedReplyThread
			delivery.ReplyThreadUpdated = &message.ReplyThreadUpdated{
				ParentMessageID: delivery.MessageID,
				ThreadChannelID: strings.TrimSpace(updatedReplyThread.ThreadChannelID),
				ReplyCount:      updatedReplyThread.ReplyCount,
				LastReplyAt:     updatedReplyThread.LastReplyAt,
				ParticipantIDs:  updatedReplyThread.ParticipantIDs,
				Participants:    updatedReplyThread.Participants,
				ReplyAuthorIDs:  updatedReplyThread.ReplyAuthorIDs,
				ReplyAuthors:    updatedReplyThread.ReplyAuthors,
			}
		}
		if strings.TrimSpace(prepared.liveRoutingContent) != "" {
			if _, err := store.handleAppMentionDeliveriesForMessage(
				ctx,
				tx,
				prepared.membership.Member.ID,
				string(domain.MemberTypeHuman),
				workspaceID,
				prepared.target,
				delivery.MessageID,
				prepared.normalizedMessageType,
				prepared.trimmedContent,
			); err != nil {
				return err
			}
		}
		appMessage = appMessageFromDelivery(
			appChannelIDForTarget(prepared.target),
			humanAuthorProfileFromUser(user, prepared.membership.Member.ID, prepared.membership.Member.DisplayName),
			delivery,
		)
		if !delivery.Message.Source.IsZero() {
			appMessage.Source = delivery.Message.Source.Normalize()
		} else if !options.Source.IsZero() {
			appMessage.Source = options.Source.Normalize()
		}
		// A workflow_run/schedule_run source identifies an automation-run message
		// written under the frozen Human authorization/audit principal. The
		// visible canonical author is the Workflow/Schedule service author, not
		// that Human. Apply the canonical automation author so every post-commit
		// projection (realtime, fanout, receive, notification) carries it.
		message.ApplyAutomationServiceAuthorToAppMessage(&appMessage)
		if err := attachResolvedMentionsForSentAppMessage(ctx, tx, workspaceID, &appMessage); err != nil {
			return err
		}
		attachAppMessageToDelivery(&delivery, &appMessage)
		if err := store.persistScopeFanoutPayloads(ctx, tx, delivery); err != nil {
			return err
		}
		return nil
	}); err != nil {
		if errors.Is(err, message.ErrContentModerationBlocked) && moderationBlock != nil {
			if insertErr := store.insertModerationBlock(ctx, store.db, *moderationBlock); insertErr != nil {
				return message.AppMessage{}, message.MessageDelivery{}, insertErr
			}
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
		if clientMessageID != "" && targetScopeID != "" && uniqueViolationMatches(err, "scope_messages_client_message_id_key") {
			replay, found, loadErr := store.loadAppMessageReplayByClientMessageID(ctx, store.db, workspaceID, targetScopeID, appChannelIDForTarget(prepared.target), prepared.membership.Member.ID, prepared.membership.Member.ID, clientMessageID)
			if loadErr != nil {
				return message.AppMessage{}, message.MessageDelivery{}, loadErr
			}
			if found {
				if err := validateClientMessageIDReplaySideEffect(replay.startedAgentThread, options); err != nil {
					return message.AppMessage{}, message.MessageDelivery{}, err
				}
				requestedDigest, digestErr := appMessageCreateDigest(prepared, options)
				if digestErr != nil {
					return message.AppMessage{}, message.MessageDelivery{}, digestErr
				}
				if !strings.EqualFold(replay.idempotencyPayloadDigest, requestedDigest) {
					return message.AppMessage{}, message.MessageDelivery{}, conflict("clientMessageId was already used with a different message payload")
				}
				existing := replay.message
				appMessage := existing
				if !options.Source.IsZero() {
					appMessage.Source = options.Source.Normalize()
				}
				replayDelivery := message.MessageDelivery{
					MessageID:        appMessage.ID,
					GlobalSeq:        appMessage.Seq,
					IdempotentReplay: true,
					AppMessage:       &appMessage,
				}
				if options.StartAgentThread {
					replyThread, err := loadStartedReplyThreadReplay(ctx, store.db, prepared, strings.TrimSpace(workspaceID), appMessage.ID)
					if err != nil {
						return message.AppMessage{}, message.MessageDelivery{}, err
					}
					replayDelivery.StartedReplyThread = &replyThread
				}
				return appMessage, replayDelivery, nil
			}
		}
		return message.AppMessage{}, message.MessageDelivery{}, err
	}

	if replayedMessage != nil {
		appMessage := *replayedMessage
		delivery.AppMessage = &appMessage
		return appMessage, delivery, nil
	}

	return appMessage, delivery, nil
}

func loadStartedReplyThreadReplay(ctx context.Context, queryer coredb.Queryer, prepared preparedUserAppMessage, workspaceID string, parentMessageID string) (message.ReplyThreadSummary, error) {
	row := queryer.QueryRow(ctx, `
SELECT child.id, child.visible_message_count, child.visible_last_message_at
FROM scopes parent
JOIN scopes child
  ON child.parent_scope_id = parent.id
 AND child.workspace_id = parent.workspace_id
 AND child.type = 'thread'
 AND child.root_message_id = $3
 AND child.status = 'active'
 AND child.archived_at IS NULL
WHERE parent.workspace_id = $1
  AND parent.id = $2
  AND parent.status = 'active'
  AND parent.archived_at IS NULL
LIMIT 1
`, strings.TrimSpace(workspaceID), message.ScopeIDForThreadAccess(prepared.target), strings.TrimSpace(parentMessageID))

	var threadChannelID string
	var replyCount int64
	var lastReplyAt *time.Time
	if err := row.Scan(&threadChannelID, &replyCount, &lastReplyAt); err != nil {
		if coredb.IsNotFound(err) {
			return message.ReplyThreadSummary{}, conflict("clientMessageId was already used without starting an agent thread")
		}
		return message.ReplyThreadSummary{}, fmt.Errorf("load started reply thread replay: %w", err)
	}
	rootInParent, err := appMessageExistsInScope(ctx, queryer, message.ScopeIDForThreadAccess(prepared.target), parentMessageID)
	if err != nil {
		return message.ReplyThreadSummary{}, err
	}
	if !rootInParent && replyCount > 0 {
		// Legacy started-thread writes stored the root message in the child thread
		// scope, so the persisted visible count includes that opener. New writes
		// keep the single visible root message in the parent scope and only create
		// an empty child thread, so no adjustment is needed for them.
		replyCount--
		if replyCount == 0 {
			lastReplyAt = nil
		}
	}
	participantMap, authorMap, err := loadReplyThreadParticipantAndAuthorDetailsMaps(ctx, queryer, []string{threadChannelID})
	if err != nil {
		return message.ReplyThreadSummary{}, err
	}
	participants := participantMap[threadChannelID]
	replyAuthors := authorMap[threadChannelID]
	return message.ReplyThreadSummary{
		ThreadChannelID: threadChannelID,
		RootMessageID:   strings.TrimSpace(parentMessageID),
		ReplyCount:      replyCount,
		LastReplyAt:     lastReplyAt,
		ParticipantIDs:  message.MergeReplyThreadParticipantIDs(nil, messageParticipantIDsFromDetails(participants)),
		Participants:    participants,
		ReplyAuthorIDs:  message.ReplyThreadRecentAuthorIDsFromDetails(replyAuthors),
		ReplyAuthors:    replyAuthors,
	}, nil
}

func messageParticipantIDsFromDetails(participants []message.ReplyThreadParticipant) []string {
	ids := make([]string, 0, len(participants))
	for _, participant := range participants {
		if trimmedID := strings.TrimSpace(participant.ID); trimmedID != "" {
			ids = append(ids, trimmedID)
		}
	}
	return ids
}

func appMessageExistsInScope(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, scopeID string, messageID string) (bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT 1
FROM scope_messages
WHERE scope_id = $1
  AND id = $2
LIMIT 1
`, strings.TrimSpace(scopeID), strings.TrimSpace(messageID))
	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("load scoped message marker: %w", err)
	}
	return true, nil
}

func validateClientMessageIDReplaySideEffect(existingStartedAgentThread bool, options SendAppMessageOptions) error {
	if existingStartedAgentThread == options.StartAgentThread {
		return nil
	}
	if existingStartedAgentThread {
		return conflict("clientMessageId was already used starting an agent thread")
	}
	return conflict("clientMessageId was already used without starting an agent thread")
}

type appMessageReplayCandidate struct {
	message                  message.AppMessage
	startedAgentThread       bool
	idempotencyPayloadDigest string
}

func appMessageCreateDigest(prepared preparedUserAppMessage, options SendAppMessageOptions) (string, error) {
	metadata, _, err := message.EncodeMessageMetadata(message.MetadataWithoutDerivedWorkflowResponder(prepared.metadata))
	if err != nil {
		return "", err
	}
	sourceMessageIDs := appOwnerQuoteSourceMessageIDs(prepared.normalizedMessageType, prepared.idempotencyContent, options.OwnerQuoteSourceMessageIDs)
	sourceRefs := make([]message.SourceRef, 0, len(sourceMessageIDs))
	for _, sourceMessageID := range sourceMessageIDs {
		sourceRefs = append(sourceRefs, message.SourceRef{
			SourceMessageID: strings.TrimSpace(sourceMessageID),
			SourceKind:      message.MessageTypeOwnerQuote,
		})
	}
	return message.ComputeMessageCreatePayloadDigest(message.MessageCreateDigestInput{
		MessageType:   prepared.normalizedMessageType,
		Content:       prepared.idempotencyContent,
		AttachmentIDs: prepared.normalizedAttachmentIDs,
		SourceRefs:    sourceRefs,
		Metadata:      metadata,
	})
}

func appOwnerQuoteSourceMessageIDs(messageType string, content string, explicitSourceMessageIDs []string) []string {
	if message.ClassifyMessageSemantics(messageType, nil).RenderKind != message.MessageRenderOwnerQuote {
		return nil
	}
	if len(explicitSourceMessageIDs) > 0 {
		return normalizeNonEmptyStrings(explicitSourceMessageIDs)
	}
	return message.ParseOwnerQuoteSourceMessageIDs(content)
}

func (store *Store) loadAppMessageReplayByClientMessageID(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, scopeID string, channelID string, memberID string, idempotencySubjectMemberID string, clientMessageID string) (appMessageReplayCandidate, bool, error) {
	existing, digest, found, err := store.loadAppMessageByClientMessageID(ctx, queryer, workspaceID, scopeID, channelID, memberID, idempotencySubjectMemberID, clientMessageID)
	if err != nil {
		return appMessageReplayCandidate{}, false, err
	}
	if found {
		started, err := appMessageHasReplyThread(ctx, queryer, workspaceID, scopeID, existing.ID)
		if err != nil {
			return appMessageReplayCandidate{}, false, err
		}
		return appMessageReplayCandidate{message: existing, startedAgentThread: started, idempotencyPayloadDigest: digest}, true, nil
	}

	startedThreadMessage, digest, found, err := store.loadStartedAgentThreadMessageByClientMessageID(ctx, queryer, workspaceID, scopeID, channelID, memberID, idempotencySubjectMemberID, clientMessageID)
	if err != nil || !found {
		return appMessageReplayCandidate{}, found, err
	}
	return appMessageReplayCandidate{message: startedThreadMessage, startedAgentThread: true, idempotencyPayloadDigest: digest}, true, nil
}

func appMessageHasReplyThread(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, parentScopeID string, parentMessageID string) (bool, error) {
	row := queryer.QueryRow(ctx, `
SELECT 1
FROM scopes
WHERE workspace_id = $1
  AND parent_scope_id = $2
  AND root_message_id = $3
  AND type = 'thread'
  AND status = 'active'
  AND archived_at IS NULL
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(parentScopeID), strings.TrimSpace(parentMessageID))
	var marker int
	if err := row.Scan(&marker); err != nil {
		if coredb.IsNotFound(err) {
			return false, nil
		}
		return false, fmt.Errorf("load message reply thread side effect: %w", err)
	}
	return true, nil
}

func (store *Store) loadStartedAgentThreadMessageByClientMessageID(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, parentScopeID string, channelID string, memberID string, idempotencySubjectMemberID string, clientMessageID string) (message.AppMessage, string, bool, error) {
	var digest string
	row := queryer.QueryRow(ctx, `
SELECT
    m.id,
    m.seq,
    $3 AS channel_id,
    author_member.type,
    author_member.id,
    CASE WHEN author_member.type = 'human' THEN COALESCE(u.id, '') ELSE '' END,
    CASE WHEN author_member.type = 'agent' THEN COALESCE(a.id, '') ELSE '' END,
    CASE WHEN author_member.type = 'agent' THEN COALESCE(a.name, '') ELSE '' END,
    CASE WHEN author_member.type = 'human' THEN COALESCE(u.display_name, '') ELSE '' END,
    author_member.display_name,
    CASE WHEN author_member.type = 'human' THEN COALESCE(u.name, '') ELSE '' END,
    CASE WHEN author_member.type = 'human' THEN COALESCE(u.avatar_url, '') ELSE '' END,
    CASE WHEN author_member.type = 'agent' THEN COALESCE(a.avatar_url, '') ELSE '' END,
    COALESCE(m.client_message_id, ''),
    m.message_type,
    m.content,
    m.metadata,
    m.created_at,
    m.edited_at,
    COALESCE(m.edited_by_member_id, ''),
    m.edit_revision,
    COALESCE(m.idempotency_payload_digest, '')
FROM scope_messages m
JOIN scopes thread_scope ON thread_scope.id = m.scope_id
JOIN members author_member ON author_member.id = m.author_member_id
LEFT JOIN users u ON author_member.type = 'human' AND u.id = author_member.user_id
LEFT JOIN agents a ON author_member.type = 'agent' AND a.member_id = author_member.id
WHERE m.workspace_id = $1
  AND thread_scope.workspace_id = $1
  AND thread_scope.type = 'thread'
  AND thread_scope.parent_scope_id = $2
  AND thread_scope.root_message_id = m.id
  AND thread_scope.status = 'active'
  AND thread_scope.archived_at IS NULL
  AND m.author_member_id = $4
  AND COALESCE(NULLIF(m.idempotency_subject_member_id, ''), m.author_member_id) = $5
  AND m.client_message_id = $6
ORDER BY m.seq ASC
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(parentScopeID), strings.TrimSpace(channelID), strings.TrimSpace(memberID), strings.TrimSpace(idempotencySubjectMemberID), strings.TrimSpace(clientMessageID))
	appMessage, err := scanAppMessageRowWithDigest(row, &digest)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return message.AppMessage{}, "", false, nil
		}
		return message.AppMessage{}, "", false, err
	}
	attachmentLookup, err := store.loadAttachmentsForMessages(ctx, workspaceID, []string{appMessage.ID})
	if err != nil {
		return message.AppMessage{}, "", false, err
	}
	appMessage.Attachments = attachmentLookup[appMessage.ID]
	return appMessage, digest, true, nil
}

func (store *Store) loadAppMessageByClientMessageID(ctx context.Context, queryer interface {
	QueryRow(context.Context, string, ...any) coredb.Row
}, workspaceID string, scopeID string, channelID string, memberID string, idempotencySubjectMemberID string, clientMessageID string) (message.AppMessage, string, bool, error) {
	var digest string
	row := queryer.QueryRow(ctx, `
SELECT
    m.id,
    m.seq,
    $3 AS channel_id,
    author_member.type,
    author_member.id,
    u.id,
    a.id,
    a.name,
    u.display_name,
    author_member.display_name,
    u.name,
    u.avatar_url,
    a.avatar_url,
    m.client_message_id,
    m.message_type,
    m.content,
    m.metadata,
    m.created_at,
    m.edited_at,
    COALESCE(m.edited_by_member_id, ''),
    m.edit_revision,
    COALESCE(m.idempotency_payload_digest, '')
FROM scope_messages m
JOIN members author_member ON author_member.id = m.author_member_id
LEFT JOIN users u ON author_member.type = 'human' AND u.id = author_member.user_id
LEFT JOIN agents a ON author_member.type = 'agent' AND a.member_id = author_member.id
WHERE m.workspace_id = $1
  AND m.scope_id = $2
  AND m.author_member_id = $4
  AND COALESCE(NULLIF(m.idempotency_subject_member_id, ''), m.author_member_id) = $5
  AND m.client_message_id = $6
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(scopeID), strings.TrimSpace(channelID), strings.TrimSpace(memberID), strings.TrimSpace(idempotencySubjectMemberID), strings.TrimSpace(clientMessageID))
	appMessage, err := scanAppMessageRowWithDigest(row, &digest)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return message.AppMessage{}, "", false, nil
		}
		return message.AppMessage{}, "", false, err
	}
	attachmentLookup, err := store.loadAttachmentsForMessages(ctx, workspaceID, []string{appMessage.ID})
	if err != nil {
		return message.AppMessage{}, "", false, err
	}
	appMessage.Attachments = attachmentLookup[appMessage.ID]
	return appMessage, digest, true, nil
}

func (store *Store) prepareAppMessageForUserTx(ctx context.Context, tx coredb.Tx, user model.User, workspaceID string, channelID string, content string, attachmentIDs []string, options SendAppMessageOptions) (preparedUserAppMessage, error) {
	if err := message.ValidateWorkflowCallbackResponderRouting(options.Metadata, options.AllowWorkflowContextMetadata, options.RequireSingleWorkflowCallbackResponder); err != nil {
		return preparedUserAppMessage{}, badRequest(err.Error())
	}
	workflowCallbackResponderAgentRef := strings.TrimSpace(options.WorkflowCallbackResponderAgentRef)
	if err := message.ValidateWorkflowCallbackResponderAgentRef(
		options.Metadata,
		options.RequireSingleWorkflowCallbackResponder,
		workflowCallbackResponderAgentRef,
	); err != nil {
		return preparedUserAppMessage{}, badRequest(err.Error())
	}
	if err := message.ValidateWorkflowContextMetadata(options.Metadata, options.AllowWorkflowContextMetadata); err != nil {
		return preparedUserAppMessage{}, badRequest(err.Error())
	}
	callbackTaskWatchHandoff, err := message.NormalizeWorkflowCallbackTaskWatchHandoff(
		options.WorkflowCallbackTaskWatchHandoff,
		options.AllowWorkflowContextMetadata,
		options.RequireSingleWorkflowCallbackResponder,
		workflowCallbackResponderAgentRef,
	)
	if err != nil {
		return preparedUserAppMessage{}, badRequest(err.Error())
	}
	if callbackTaskWatchHandoff != nil {
		return preparedUserAppMessage{}, badRequest("workflow callback Task watch handoff requires a Task discussion destination")
	}
	if options.RequireSingleWorkflowCallbackResponder {
		if options.StartAgentThread {
			return preparedUserAppMessage{}, badRequest("single workflow callback responder routing does not support starting an agent thread")
		}
		if len(options.ExplicitRecipients) > 0 || len(options.ExplicitWakeTargets) > 0 {
			return preparedUserAppMessage{}, badRequest("single workflow callback responder routing does not accept caller-provided recipients")
		}
	}
	trimmedChannelID := strings.TrimSpace(channelID)
	if trimmedChannelID == "" {
		return preparedUserAppMessage{}, badRequest("channelId is required")
	}
	if _, ok := message.NormalizeAgentInterruptPolicy(options.InterruptPolicy); !ok {
		return preparedUserAppMessage{}, badRequest("unsupported interruptPolicy")
	}

	normalizedAttachmentIDs, err := normalizeAttachmentIDs(attachmentIDs)
	if err != nil {
		return preparedUserAppMessage{}, err
	}

	trimmedContent := strings.TrimSpace(content)
	if trimmedContent == "" && len(normalizedAttachmentIDs) == 0 {
		return preparedUserAppMessage{}, badRequest("content is required")
	}
	normalizedMessageType := message.NormalizeAppMessageType(options.MessageType)
	if strings.TrimSpace(options.MessageType) != "" && normalizedMessageType == "" {
		return preparedUserAppMessage{}, badRequest("unsupported messageType")
	}
	messageSemantics := message.ClassifyMessageSemantics(normalizedMessageType, nil)
	if messageSemantics.RenderKind == message.MessageRenderSticker {
		if trimmedContent != "" {
			return preparedUserAppMessage{}, badRequest("sticker messages do not support text")
		}
		if len(normalizedAttachmentIDs) == 0 {
			return preparedUserAppMessage{}, badRequest("sticker messages require attachments")
		}
	}

	expectedAuthorMemberID := strings.TrimSpace(options.ExpectedAuthorMemberID)
	var membership access.HumanWorkspaceMember
	var target message.ThreadAccess
	if expectedAuthorMemberID != "" {
		// Lock the owner's active workspace membership before any idempotency
		// lookup or message/outbox write. This makes membership rebinding and a
		// delayed workflow send mutually exclusive at the transaction boundary:
		// whichever commits first determines whether the frozen identity is still
		// valid, without ever emitting under a replacement member.
		current, err := store.loadAuthorizedHumanWorkspaceMemberForUpdate(ctx, tx, user.ID, workspaceID, workspaceAccessAction)
		if err != nil {
			if errors.Is(err, ErrForbidden) {
				return preparedUserAppMessage{}, conflict("workflow Run frozen author member is unavailable")
			}
			return preparedUserAppMessage{}, err
		}
		if strings.TrimSpace(current.Member.ID) != expectedAuthorMemberID {
			return preparedUserAppMessage{}, conflict("workflow Run frozen author member is unavailable")
		}
		membership, target, err = store.resolveAuthorizedHumanMessageTargetWithQueryer(ctx, tx, user.ID, workspaceID, trimmedChannelID, channelPostAction, dmPostAction, threadPostAction)
	} else {
		// Preserve the ordinary Human message path for callers that do not request
		// an identity fence. Only delayed, frozen-author writes take the stronger
		// transaction-local authorization path above.
		membership, target, err = store.resolveAuthorizedHumanMessageTarget(ctx, user.ID, workspaceID, trimmedChannelID, channelPostAction, dmPostAction, threadPostAction)
	}
	if err != nil {
		return preparedUserAppMessage{}, err
	}
	routingPrincipalMemberID := strings.TrimSpace(options.RoutingPrincipalMemberID)
	if routingPrincipalMemberID == "" {
		routingPrincipalMemberID = membership.Member.ID
	} else if err := validateHumanRoutingPrincipalTx(ctx, tx, workspaceID, user.ID, routingPrincipalMemberID); err != nil {
		return preparedUserAppMessage{}, err
	}
	if messageSemantics.RenderKind == message.MessageRenderOwnerQuote {
		if err := store.validateOwnerQuoteMessageForTarget(ctx, tx, workspaceID, target, trimmedContent); err != nil {
			return preparedUserAppMessage{}, err
		}
	}
	idempotencyContent := trimmedContent
	metadata := message.CopyMessageMetadata(options.Metadata)
	if interruptPolicy := strings.TrimSpace(options.InterruptPolicy); interruptPolicy != "" {
		metadata = message.MetadataWithAgentDeliveryInterruptPolicy(metadata, interruptPolicy)
	}
	mentionHints := append([]message.MessageMentionHint(nil), options.MentionHints...)
	var workflowCallbackResolvedResponder *message.AgentRecipient
	// workflowFrozenMemberContext carries the server-frozen minimal member
	// context of the callback responder so the delivery recipient can be
	// decorated with a Workflow-specific hidden Team context (never the full
	// @Team roster).
	var workflowFrozenMemberContext *message.WorkflowTeamMemberContextWire
	var workflowCallbackResponderScopeID string
	if options.RequireSingleWorkflowCallbackResponder && workflowCallbackResponderAgentRef != "" && !options.skipWorkflowCallbackRouting {
		workflowCallbackResponderScopeID = strings.TrimSpace(target.ChannelID)
		if target.ReplyThread != nil {
			workflowCallbackResponderScopeID = strings.TrimSpace(target.ReplyThread.ParentChannelID)
		}
		var responder message.AgentRecipient
		var resolveErr error
		if target.ThreadType == domain.ThreadTypeDM {
			responder, resolveErr = resolveWorkflowCallbackDMResponderByRef(
				ctx,
				tx,
				workspaceID,
				target,
				workflowCallbackResponderAgentRef,
				routingPrincipalMemberID,
			)
		} else {
			responder, resolveErr = resolveVisibleChannelAgentRecipientByRef(
				ctx,
				tx,
				workspaceID,
				workflowCallbackResponderScopeID,
				workflowCallbackResponderAgentRef,
				routingPrincipalMemberID,
				routingPrincipalMemberID,
			)
		}
		if resolveErr != nil {
			return preparedUserAppMessage{}, resolveErr
		}
		// P0 fence: the canonically resolved responder must match the frozen Agent
		// ID the dispatcher persisted before delivery. A mismatch means a mutable
		// alias/visibility now names a different Agent; fail closed in this
		// transaction so no message/outbox/fanout/wake side effect commits.
		expectedFrozenResponder := strings.TrimSpace(options.WorkflowCallbackExpectedResponderAgentID)
		if expectedFrozenResponder != "" && strings.TrimSpace(responder.AgentID) != expectedFrozenResponder {
			return preparedUserAppMessage{}, conflict("workflow callback responder changed after freeze")
		}
		var responderHint message.MessageMentionHint
		trimmedContent, responderHint, resolveErr = materializeWorkflowCallbackResponderMention(trimmedContent, responder)
		if resolveErr != nil {
			return preparedUserAppMessage{}, resolveErr
		}
		mentionHints = append(mentionHints, responderHint)
		if metadata == nil {
			metadata = make(map[string]any, 1)
		}
		if err := materializeWorkflowRecoveryResponderPresentation(metadata, responder); err != nil {
			return preparedUserAppMessage{}, err
		}
		metadata[message.WorkflowContextMetadataResponderAgentIDKey] = strings.TrimSpace(responder.AgentID)
		workflowCallbackResolvedResponder = &responder
		// P0 security fence: a callback Run with a frozen Team ID (Team-linked
		// or dynamic-explicit) must authorize the resolved responder as an
		// active member of the frozen Run Team BEFORE the message is persisted
		// or fanned out. A rejected responder returns an error here, rolling
		// back the whole transaction with zero side effects
		// (no message row, no fan-out, no wake, no unread/notification). The
		// frozen minimal member context is atomically persisted into the same
		// message metadata so callback/retry/recovery/replay reuse one snapshot.
		if teamID := strings.TrimSpace(options.WorkflowRunTeamID); teamID != "" {
			memberContext, memberErr := resolveWorkflowRunTeamMemberWithQueryer(
				ctx, tx, workspaceID, teamID, strings.TrimSpace(responder.AgentID),
			)
			if memberErr != nil {
				return preparedUserAppMessage{}, memberErr
			}
			encoded := message.EncodeWorkflowTeamMemberContextMetadata(
				workflowMemberContextToWire(memberContext),
			)
			if len(encoded) > 0 {
				metadata[message.WorkflowContextMetadataTeamMemberContextKey] = encoded
			}
			workflowFrozenMemberContext = workflowMemberContextToWire(memberContext)
		}
	}
	liveRoutingContent := mentionRoutingContent(normalizedMessageType, trimmedContent)
	controlledBroadcastContent := liveRoutingContent
	if messageSemantics.RenderKind == message.MessageRenderOwnerQuote {
		controlledBroadcastContent = message.ExtractOwnerQuoteInstruction(trimmedContent)
	}
	if options.StartAgentThread {
		if target.ThreadType != domain.ThreadTypeChannel || target.ReplyThread != nil {
			return preparedUserAppMessage{}, badRequest("also start a thread only supports channel messages")
		}
	}
	if options.skipWorkflowCallbackRouting {
		return preparedUserAppMessage{
			membership:               membership,
			target:                   target,
			trimmedContent:           trimmedContent,
			idempotencyContent:       idempotencyContent,
			normalizedAttachmentIDs:  normalizedAttachmentIDs,
			normalizedMessageType:    normalizedMessageType,
			metadata:                 metadata,
			liveRoutingContent:       liveRoutingContent,
			routingPrincipalMemberID: routingPrincipalMemberID,
		}, nil
	}
	var explicitRoutingPlan *message.AgentRoutingPlan
	var privateMentionRecipients []message.AgentRecipient
	var startAgentThreadRecipients []message.AgentRecipient
	var mentionMetadataRecipients []message.AgentRecipient
	var mentionMetadataTeamMentions []message.PersistedMention
	var mentionMetadataTokens []string
	var workflowCallbackDirectRecipients []message.AgentRecipient
	var workflowCallbackHasTeamMention bool
	mentionMetadataHints := mentionHints
	usesExplicitDeliveryProfile := messageSemantics.Class == message.MessageClassTranscript && messageSemantics.DeliveryProfile == message.MessageDeliveryExplicit
	if usesExplicitDeliveryProfile || len(options.ExplicitRecipients) > 0 || len(options.ExplicitWakeTargets) > 0 {
		explicitRoutingPlan = &message.AgentRoutingPlan{
			DurableRecipients: options.ExplicitRecipients,
			WakeRecipients:    options.ExplicitWakeTargets,
		}
	}

	allowControlledBroadcast := false
	if explicitRoutingPlan == nil {
		allowControlledBroadcast, err = authorizeControlledBroadcastForUserMessage(target, controlledBroadcastContent)
		if err != nil {
			return preparedUserAppMessage{}, err
		}
		if target.ReplyThread != nil {
			if err := message.ValidateReplyThreadRoutingContent(controlledBroadcastContent); err != nil {
				return preparedUserAppMessage{}, err
			}
			if err := store.syncReplyThreadSubscriptions(
				ctx,
				tx,
				membership.Member.ID,
				domain.MemberTypeHuman,
				target.ChannelID,
				target.ReplyThread.ParentMessageID,
				nil,
				nil,
				0,
				0,
				time.Now().UTC(),
			); err != nil {
				return preparedUserAppMessage{}, err
			}
		}
		if target.ThreadType != domain.ThreadTypeDM {
			recipients := make([]message.AgentRecipient, 0)
			var mentionedRecipients []message.AgentRecipient
			var directMentionRecipients []message.AgentRecipient
			mentionContent := liveRoutingContent
			mentionChannelID := strings.TrimSpace(target.ChannelID)
			if target.ReplyThread != nil {
				mentionChannelID = strings.TrimSpace(target.ReplyThread.ParentChannelID)
			}
			workflowCallbackResponderScopeID = mentionChannelID
			resolvedMentionLinks := message.ParseResolvedMentionLinks(mentionContent)
			resolvedMentionHints := message.MentionHintsWithResolvedLinks(mentionHints, resolvedMentionLinks)
			mentionMetadataHints = resolvedMentionHints
			directMentionTokens := message.ParseDirectMentionTokens(mentionContent)
			canonicalMentionTokens := resolvedMentionTokensForDirectTokens(resolvedMentionLinks, directMentionTokens)
			mentionMetadataTokens = directMentionTokens
			hintRouting, err := resolveMentionHintAgentRecipientsForUser(
				ctx,
				tx,
				workspaceID,
				mentionChannelID,
				routingPrincipalMemberID,
				resolvedMentionHints,
				directMentionTokens,
				resolvedMentionLinks,
			)
			if err != nil {
				return preparedUserAppMessage{}, err
			}
			teamFallbackExclusions := append(append([]string(nil), hintRouting.claimedTokens...), canonicalMentionTokens...)
			teamFallbackTokens := directMentionTokensForTeamFallback(directMentionTokens, teamFallbackExclusions)
			teamRouting, err := resolveTeamMentionRecipientsForUser(
				ctx,
				team.New(store.db).InTx(tx),
				workspaceID,
				mentionChannelID,
				routingPrincipalMemberID,
				resolvedMentionHints,
				directMentionTokens,
				teamFallbackTokens,
			)
			if err != nil {
				return preparedUserAppMessage{}, err
			}
			mentionMetadataTeamMentions = teamRouting.mentions
			workflowCallbackHasTeamMention = len(teamRouting.recipients) > 0 || len(teamRouting.mentions) > 0
			excludedMentionTokens := append(append([]string(nil), teamFallbackExclusions...), teamRouting.tokens...)
			liveDirectMentionTokens := directMentionTokensExcluding(directMentionTokens, excludedMentionTokens)
			ownerPrivateRouting, err := store.resolveOwnerPrivateRecipientsForMessage(
				ctx,
				tx,
				workspaceID,
				target,
				routingPrincipalMemberID,
				domain.MemberTypeHuman,
				mentionContent,
				ownerPrivateRecipientResolutionOptions{
					IncludeDirectMentions:  len(directMentionTokens) > 0,
					IncludeChannelAutoWake: target.ReplyThread == nil,
					IncludeThreadContext:   target.ReplyThread != nil,
					DirectMentionTokens:    liveDirectMentionTokens,
				},
			)
			if err != nil {
				return preparedUserAppMessage{}, err
			}
			privateMentionRecipients = message.MergeAgentRecipients(ownerPrivateRouting.DirectMentionRecipients, hintRouting.privateRecipients)
			if target.ReplyThread != nil {
				threadParticipants, err := message.LoadScopeThreadParticipantAgentRecipients(
					ctx,
					tx,
					target.ChannelID,
					routingPrincipalMemberID,
				)
				if err != nil {
					return preparedUserAppMessage{}, err
				}
				recipients = message.MergeAgentRecipients(recipients, threadParticipants)
				recipients = message.MergeAgentRecipients(recipients, ownerPrivateRouting.DurableRecipients)
			}
			if len(directMentionTokens) > 0 {
				if len(liveDirectMentionTokens) > 0 {
					directMentionRecipients, err = message.LoadScopeMentionedAgentRecipientsForTokens(ctx, tx, mentionChannelID, routingPrincipalMemberID, liveDirectMentionTokens)
					if err != nil {
						return preparedUserAppMessage{}, err
					}
				}
				directMentionRecipients = message.MergeAgentRecipients(directMentionRecipients, hintRouting.recipients, privateMentionRecipients)
				workflowCallbackDirectRecipients = directMentionRecipients
				mentionedRecipients = message.MergeAgentRecipients(directMentionRecipients, teamRouting.recipients)
				if options.StartAgentThread {
					startAgentThreadRecipients = message.MergeAgentRecipients(startAgentThreadRecipients, mentionedRecipients)
				}
				mentionMetadataRecipients = message.MergeAgentRecipients(mentionMetadataRecipients, mentionedRecipients)
				recipients = message.MergeAgentRecipients(recipients, mentionedRecipients)
			}
			if allowControlledBroadcast && target.ReplyThread == nil {
				ownedBroadcastPlan, err := message.LoadOwnedScopeBroadcastRoutingPlan(
					ctx,
					tx,
					user.ID,
					mentionChannelID,
					routingPrincipalMemberID,
					controlledBroadcastContent,
				)
				if err != nil {
					return preparedUserAppMessage{}, err
				}
				explicitRoutingPlan = &message.AgentRoutingPlan{
					DurableRecipients: message.MergeAgentRecipients(ownedBroadcastPlan.DurableRecipients, mentionedRecipients),
					WakeRecipients:    message.MergeAgentRecipients(ownedBroadcastPlan.WakeRecipients, mentionedRecipients),
				}
			} else if target.ReplyThread == nil {
				defaultRecipients, err := message.LoadScopeDefaultAgentRecipients(
					ctx,
					tx,
					mentionChannelID,
					routingPrincipalMemberID,
				)
				if err != nil {
					return preparedUserAppMessage{}, err
				}
				defaultRecipients = message.MergeAgentRecipients(defaultRecipients, ownerPrivateRouting.DurableRecipients)
				wakeRecipients := message.MergeAgentRecipients(message.FilterAutoWakeAgentRecipients(defaultRecipients), mentionedRecipients, ownerPrivateRouting.WakeRecipients)
				if teamRouting.suppressDefaultAutoWake {
					wakeRecipients = mentionedRecipients
				}
				explicitRoutingPlan = &message.AgentRoutingPlan{
					DurableRecipients: message.MergeAgentRecipients(defaultRecipients, mentionedRecipients),
					WakeRecipients:    wakeRecipients,
				}
			} else {
				wakeRecipients := message.MergeAgentRecipients(message.FilterAutoWakeAgentRecipients(recipients), mentionedRecipients, ownerPrivateRouting.WakeRecipients)
				if teamRouting.suppressDefaultAutoWake {
					wakeRecipients = mentionedRecipients
				}
				explicitRoutingPlan = &message.AgentRoutingPlan{
					DurableRecipients: recipients,
					WakeRecipients:    wakeRecipients,
				}
			}
		}
	}

	if mentions := message.BuildPersistedMentionMetadataWithTeams(mentionMetadataHints, mentionMetadataRecipients, mentionMetadataTeamMentions, mentionMetadataTokens); len(mentions) > 0 {
		if metadata == nil {
			metadata = make(map[string]any, 1)
		}
		metadata[message.MentionMetadataKey] = mentions
	}

	if options.StartAgentThread && len(startAgentThreadRecipients) > 0 {
		// Explicit agent mentions that also start a thread target the newly
		// opened reply thread for the initial delivery. Keep this limited to
		// agents named by the message (public/private direct mentions and Team
		// mention responders) so channel auto-wake and broadcast recipients do
		// not get pulled into the started thread.
		explicitRoutingPlan = &message.AgentRoutingPlan{
			DurableRecipients: startAgentThreadRecipients,
			WakeRecipients:    startAgentThreadRecipients,
		}
	}

	if options.RequireSingleWorkflowCallbackResponder {
		var responders []message.AgentRecipient
		if target.ThreadType == domain.ThreadTypeDM {
			workflowCallbackResponderScopeID = message.ScopeIDForThreadAccess(target)
			dmPlan, err := message.LoadScopeAgentRoutingPlan(ctx, tx, routingPrincipalMemberID, target, liveRoutingContent, false)
			if err != nil {
				return preparedUserAppMessage{}, err
			}
			responders = message.MergeAgentRecipients(dmPlan.WakeRecipients)
		} else {
			responders = message.MergeAgentRecipients(workflowCallbackDirectRecipients)
		}
		responders, err = filterActiveWorkflowCallbackResponders(ctx, tx, workflowCallbackResponderScopeID, responders)
		if err != nil {
			return preparedUserAppMessage{}, err
		}
		if workflowCallbackResolvedResponder != nil {
			selectedAgentID := strings.TrimSpace(workflowCallbackResolvedResponder.AgentID)
			selectedFound := false
			for _, responder := range responders {
				if strings.TrimSpace(responder.AgentID) == selectedAgentID {
					selectedFound = true
					break
				}
			}
			if !selectedFound {
				return preparedUserAppMessage{}, badRequest("workflow callback resolved mention did not resolve to the selected responder")
			}
		} else {
			// Legacy callback messages expressed authority only through one direct
			// mention. Once a structured responder exists, other ordinary mentions,
			// Team mentions, defaults, and watchers may still share the wake set.
			if allowControlledBroadcast {
				return preparedUserAppMessage{}, badRequest("legacy workflow callback message does not support channel broadcast mentions")
			}
			if workflowCallbackHasTeamMention {
				return preparedUserAppMessage{}, badRequest("legacy workflow callback message requires one direct Agent mention; Team mentions are not supported")
			}
			if len(responders) != 1 {
				return preparedUserAppMessage{}, badRequest(fmt.Sprintf("legacy workflow callback message requires exactly one Agent responder; resolved %d", len(responders)))
			}
			if metadata == nil {
				metadata = make(map[string]any, 1)
			}
			metadata[message.WorkflowContextMetadataResponderAgentIDKey] = strings.TrimSpace(responders[0].AgentID)
		}
		allowControlledBroadcast = false
		// For DM callbacks there is no explicit routing plan (the responder came
		// from the DM plan's wake set), so materialize one from the verified
		// responders to make the hidden Workflow-specific Team context reach the
		// delivery.
		if explicitRoutingPlan == nil {
			explicitRoutingPlan = &message.AgentRoutingPlan{
				DurableRecipients: responders,
				WakeRecipients:    responders,
			}
		}
		// Attach the frozen minimal Workflow-specific member context to the
		// callback responder's delivery recipient. This decorates the hidden
		// delivery/context chain with ONLY the responder's own Team identity,
		// teamMemberId, role label, and role overlay (no full @Team roster), so
		// the daemon prompt surfaces the frozen context rather than reusing the
		// ordinary @Team full roster.
		if workflowFrozenMemberContext != nil {
			selectedAgentID := ""
			if workflowCallbackResolvedResponder != nil {
				selectedAgentID = strings.TrimSpace(workflowCallbackResolvedResponder.AgentID)
			}
			attachWorkflowMemberTeamContextToPlan(explicitRoutingPlan, selectedAgentID, workflowFrozenMemberContext)
		}
	}

	if options.suppressAgentDelivery {
		// Mention resolution above remains authoritative for persisted rendering,
		// but the onboarding transcript must not create an Agent outbox or wake.
		explicitRoutingPlan = &message.AgentRoutingPlan{}
		startAgentThreadRecipients = nil
	}

	return preparedUserAppMessage{
		membership:                 membership,
		target:                     target,
		trimmedContent:             trimmedContent,
		idempotencyContent:         idempotencyContent,
		normalizedAttachmentIDs:    normalizedAttachmentIDs,
		normalizedMessageType:      normalizedMessageType,
		metadata:                   metadata,
		liveRoutingContent:         liveRoutingContent,
		allowControlledBroadcast:   allowControlledBroadcast,
		explicitRoutingPlan:        explicitRoutingPlan,
		routingPrincipalMemberID:   routingPrincipalMemberID,
		privateMentionRecipients:   privateMentionRecipients,
		startAgentThreadRecipients: startAgentThreadRecipients,
		workflowCallbackResponder:  workflowCallbackResolvedResponder,
		callbackTaskWatchHandoff:   callbackTaskWatchHandoff,
	}, nil
}

// attachWorkflowMemberTeamContextToPlan decorates the selected callback
// responder's delivery recipients with a Workflow-specific minimal Team
// context derived from the frozen member snapshot. It attaches ONLY the
// responder's own Team identity, teamMemberId, role, role label, and role
// overlay with an empty roster — never the full @Team roster, other members'
// duties, wake configuration, or Team division hints.
func attachWorkflowMemberTeamContextToPlan(plan *message.AgentRoutingPlan, selectedAgentID string, context *message.WorkflowTeamMemberContextWire) {
	if plan == nil || context == nil {
		return
	}
	teamContext := message.TeamDeliveryContext{
		TeamID:                    context.TeamID,
		TeamHandle:                context.TeamHandle,
		TeamDisplayName:           context.TeamDisplayName,
		TeamDescription:           context.TeamPurpose,
		TeamMemberID:              context.TeamMemberID,
		Role:                      context.Role,
		RoleLabel:                 context.RoleLabel,
		RoleOverlayPromptSnapshot: context.RoleOverlay,
		Workflow:                  true,
	}
	attachTeamContext := func(recipients []message.AgentRecipient) []message.AgentRecipient {
		for index := range recipients {
			if selectedAgentID != "" && strings.TrimSpace(recipients[index].AgentID) != selectedAgentID {
				continue
			}
			// Prefer the Workflow-specific context: replace any existing context
			// for the same (team, member) with the frozen workflow one so the
			// dedicated formatter renders it, never the ordinary @Team roster.
			replaced := false
			for existing := range recipients[index].TeamContexts {
				if strings.TrimSpace(recipients[index].TeamContexts[existing].TeamID) == teamContext.TeamID &&
					strings.TrimSpace(recipients[index].TeamContexts[existing].TeamMemberID) == teamContext.TeamMemberID {
					recipients[index].TeamContexts[existing] = teamContext
					replaced = true
					break
				}
			}
			if !replaced {
				recipients[index].TeamContexts = append(recipients[index].TeamContexts, teamContext)
			}
		}
		return recipients
	}
	plan.DurableRecipients = attachTeamContext(plan.DurableRecipients)
	plan.WakeRecipients = attachTeamContext(plan.WakeRecipients)
}

func filterActiveWorkflowCallbackResponders(ctx context.Context, queryer coredb.QueryRower, scopeID string, recipients []message.AgentRecipient) ([]message.AgentRecipient, error) {
	scopeID = strings.TrimSpace(scopeID)
	if scopeID == "" || len(recipients) == 0 {
		return nil, nil
	}
	active := make([]message.AgentRecipient, 0, len(recipients))
	for _, recipient := range message.MergeAgentRecipients(recipients) {
		var agentID string
		err := queryer.QueryRow(ctx, `
SELECT a.id
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = $1
 AND sm.member_id = a.member_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
WHERE a.id = $2
  AND a.status <> 'archived'
LIMIT 1
`, scopeID, strings.TrimSpace(recipient.AgentID)).Scan(&agentID)
		if coredb.IsNotFound(err) {
			continue
		}
		if err != nil {
			return nil, fmt.Errorf("validate workflow callback responder membership: %w", err)
		}
		active = append(active, recipient)
	}
	return message.MergeAgentRecipients(active), nil
}

func directMentionTokensForTeamFallback(tokens []string, claimedAgentHintTokens []string) []string {
	if len(tokens) == 0 {
		return nil
	}
	fallbackTokens := directMentionTokensExcluding(tokens, claimedAgentHintTokens)
	if fallbackTokens != nil {
		return fallbackTokens
	}
	return []string{}
}

func resolvedMentionTokensForDirectTokens(links []message.ResolvedMentionLink, directTokens []string) []string {
	if len(links) == 0 || len(directTokens) == 0 {
		return nil
	}
	tokens := make([]string, 0, len(links))
	seen := make(map[string]struct{}, len(links))
	for _, link := range links {
		token := strings.ToLower(strings.TrimSpace(link.Token))
		if token == "" || !mentionTokenMatchesAnyDirectToken(token, directTokens) {
			continue
		}
		if _, ok := seen[token]; ok {
			continue
		}
		seen[token] = struct{}{}
		tokens = append(tokens, token)
	}
	return tokens
}

func directMentionTokensExcluding(tokens []string, excluded []string) []string {
	if len(tokens) == 0 {
		return nil
	}
	if len(excluded) == 0 {
		return append([]string(nil), tokens...)
	}
	excludedSet := make(map[string]struct{}, len(excluded))
	for _, token := range excluded {
		normalized := strings.ToLower(strings.TrimSpace(token))
		if normalized != "" {
			excludedSet[normalized] = struct{}{}
		}
	}
	filtered := make([]string, 0, len(tokens))
	for _, token := range tokens {
		normalized := strings.ToLower(strings.TrimSpace(token))
		if normalized == "" {
			continue
		}
		if _, ok := excludedSet[normalized]; ok {
			continue
		}
		excludedByTarget := false
		for excludedToken := range excludedSet {
			if message.MentionTokenMatchesTarget(normalized, excludedToken) {
				excludedByTarget = true
				break
			}
		}
		if excludedByTarget {
			continue
		}
		filtered = append(filtered, normalized)
	}
	return filtered
}

func validateHumanRoutingPrincipalTx(ctx context.Context, tx coredb.Tx, workspaceID string, userID string, memberID string) error {
	row := tx.QueryRow(ctx, `
SELECT id
FROM members
WHERE id = $1
  AND workspace_id = $2
  AND user_id = $3
  AND type = 'human'
  AND status = 'active'
LIMIT 1
`, strings.TrimSpace(memberID), strings.TrimSpace(workspaceID), strings.TrimSpace(userID))
	var id string
	if err := row.Scan(&id); err != nil {
		if coredb.IsNotFound(err) {
			return forbidden("routing principal is not an active workspace human member")
		}
		return fmt.Errorf("load routing principal: %w", err)
	}
	return nil
}

func authorizeControlledBroadcastForUserMessage(target message.ThreadAccess, content string) (bool, error) {
	if target.ReplyThread != nil || target.ThreadType != domain.ThreadTypeChannel {
		return false, nil
	}

	broadcastMode, err := message.ParseControlledBroadcastMode(content)
	if err != nil {
		return false, err
	}
	return broadcastMode != message.ChannelBroadcastModeNone, nil
}

func retargetStartAgentThreadDelivery(ctx context.Context, tx coredb.Tx, delivery *message.MessageDelivery, parentTarget message.ThreadAccess, replyThread message.ReplyThreadSummary, recipients []message.AgentRecipient) error {
	if delivery == nil {
		return nil
	}
	threadChannelID := strings.TrimSpace(replyThread.ThreadChannelID)
	if threadChannelID == "" {
		return fmt.Errorf("started reply thread is missing channel id")
	}
	if err := activateStartedReplyThreadRecipients(ctx, tx, threadChannelID, recipients, delivery.Message.Timestamp); err != nil {
		return err
	}
	delivery.ThreadID = threadChannelID
	delivery.Message.TargetKey = threadChannelID
	delivery.Message.TargetSeq = 0
	delivery.Message.ChannelType = "thread"
	delivery.Message.ChannelName = "thread-" + shortTargetID(delivery.MessageID)
	delivery.Message.ChannelDisplayName = ""
	delivery.Message.ParentChannelType = string(parentTarget.ThreadType)
	delivery.Message.ParentChannelName = strings.TrimSpace(parentTarget.ChannelName)
	delivery.Message.ParentChannelDisplayName = ""
	return nil
}

func activateStartedReplyThreadRecipients(ctx context.Context, execer coredb.QueryExecer, threadChannelID string, recipients []message.AgentRecipient, subscribedAt time.Time) error {
	for _, memberID := range agentRecipientMemberIDs(recipients) {
		if err := message.ActivateThreadSubscription(ctx, execer, memberID, threadChannelID, subscribedAt); err != nil {
			return err
		}
	}
	return nil
}

func agentRecipientMemberIDs(recipients []message.AgentRecipient) []string {
	seen := make(map[string]struct{}, len(recipients))
	memberIDs := make([]string, 0, len(recipients))
	for _, recipient := range recipients {
		memberID := strings.TrimSpace(recipient.AgentMemberID)
		if memberID == "" {
			continue
		}
		if _, ok := seen[memberID]; ok {
			continue
		}
		seen[memberID] = struct{}{}
		memberIDs = append(memberIDs, memberID)
	}
	return memberIDs
}

func shortTargetID(id string) string {
	trimmed := strings.TrimSpace(id)
	if len(trimmed) <= 8 {
		return trimmed
	}
	return trimmed[:8]
}

func normalizeAttachmentIDs(raw []string) ([]string, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	normalized := make([]string, 0, len(raw))
	seen := make(map[string]struct{}, len(raw))
	for _, candidate := range raw {
		trimmed := strings.TrimSpace(candidate)
		if trimmed == "" {
			return nil, badRequest("invalid attachmentId")
		}
		if _, ok := seen[trimmed]; ok {
			continue
		}
		seen[trimmed] = struct{}{}
		normalized = append(normalized, trimmed)
	}
	return normalized, nil
}

func normalizeClientMessageID(raw string) (string, error) {
	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return "", nil
	}
	if len(trimmed) > 128 {
		return "", badRequest("clientMessageId is too long")
	}
	return trimmed, nil
}

func NormalizeAttachmentIDs(raw []string) ([]string, error) {
	return normalizeAttachmentIDs(raw)
}

func appMessageFromDelivery(channelID string, authorProfile message.AuthorProfile, delivery message.MessageDelivery) message.AppMessage {
	appMessage := message.AppMessage{
		ID:                delivery.MessageID,
		Seq:               delivery.Seq,
		ChannelID:         strings.TrimSpace(channelID),
		ChannelType:       strings.TrimSpace(delivery.MentionContext.ChannelType),
		ClientMessageID:   strings.TrimSpace(delivery.ClientMessageID),
		Source:            delivery.Message.Source,
		Content:           delivery.Message.Content,
		Metadata:          message.CopyMessageMetadata(delivery.Message.Metadata),
		Attachments:       delivery.Message.Attachments,
		CreatedAt:         delivery.Message.Timestamp,
		MessageType:       delivery.Message.MessageType,
		ParentChannelID:   strings.TrimSpace(delivery.MentionContext.ParentChannelID),
		ParentChannelType: strings.TrimSpace(delivery.MentionContext.ParentChannelType),
		ParentMessageID:   strings.TrimSpace(delivery.MentionContext.ParentMessageID),
	}
	appMessage.ApplyAuthorProfile(authorProfile)
	message.ApplyDefaultContributionSourceToAppMessage(&appMessage)
	return appMessage
}

func AppMessageFromDelivery(channelID string, authorProfile message.AuthorProfile, delivery message.MessageDelivery) message.AppMessage {
	return appMessageFromDelivery(channelID, authorProfile, delivery)
}

// ReadMessagesByThreadGlobalSeq exposes the app-domain timeline reader for
// integration tests that assert cross-domain visibility behavior.
func (store *Store) ReadMessagesByThreadGlobalSeq(ctx context.Context, workspaceID string, threadID string, channelID string, limit int, before *int64) ([]message.AppMessage, error) {
	return store.readMessagesByThreadGlobalSeq(ctx, workspaceID, threadID, channelID, "", limit, before)
}

func (store *Store) readMessagesByThreadGlobalSeq(ctx context.Context, workspaceID string, threadID string, channelID string, viewerMemberID string, limit int, before *int64) ([]message.AppMessage, error) {
	if limit <= 0 {
		return nil, badRequest("invalid limit")
	}

	beforeSeq := int64(math.MaxInt64)
	if before != nil {
		beforeSeq = *before
	}

	query, args := buildThreadTimelineReadQuery(channelID, viewerMemberID, limit, beforeSeq)

	rows, err := store.db.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("load messages: %w", err)
	}
	defer rows.Close()

	messages := make([]message.AppMessage, 0)
	for rows.Next() {
		appMessage, err := scanAppMessageRow(rows)
		if err != nil {
			return nil, err
		}
		messages = append(messages, appMessage)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate messages: %w", err)
	}

	attachmentLookup, err := store.loadAttachmentsForMessages(ctx, workspaceID, collectAppMessageIDs(messages))
	if err != nil {
		return nil, err
	}
	for i := range messages {
		messages[i].Attachments = attachmentLookup[messages[i].ID]
	}
	if err := store.attachAppMessageReadDecorations(ctx, workspaceID, viewerMemberID, messages); err != nil {
		return nil, err
	}

	return messages, nil
}

func (store *Store) readMessagesByThreadGlobalSeqAfter(ctx context.Context, workspaceID string, threadID string, channelID string, viewerMemberID string, limit int, afterSeq int64) ([]message.AppMessage, error) {
	if limit <= 0 {
		return nil, badRequest("invalid limit")
	}

	rows, err := store.db.Query(ctx, fmt.Sprintf(`
WITH page AS (
    SELECT id, seq, author_member_id, author_type, client_message_id, message_type, content, metadata, created_at,
           edited_at, edited_by_member_id, edit_revision
    FROM scope_messages
    WHERE scope_id = $1
      AND seq > $3
      AND %s
    ORDER BY seq ASC
    LIMIT $2
)
SELECT
    page.id,
    page.seq,
    $1 AS channel_id,
    page.author_type,
    author_member.id,
    u.id,
    a.id,
    a.name,
    u.display_name,
    author_member.display_name,
    u.name,
    u.avatar_url,
    a.avatar_url,
    page.client_message_id,
    page.message_type,
    page.content,
    page.metadata,
    page.created_at,
    page.edited_at,
    COALESCE(page.edited_by_member_id, ''),
    page.edit_revision
FROM page
JOIN members author_member ON author_member.id = page.author_member_id
LEFT JOIN users u ON page.author_type = 'human' AND u.id = author_member.user_id
LEFT JOIN agents a ON page.author_type = 'agent' AND a.member_id = author_member.id
ORDER BY page.seq ASC
`, humanVisibleMessageTypeSQL("scope_messages")), channelID, limit, afterSeq)
	if err != nil {
		return nil, fmt.Errorf("load messages: %w", err)
	}
	defer rows.Close()

	messages := make([]message.AppMessage, 0)
	for rows.Next() {
		appMessage, err := scanAppMessageRow(rows)
		if err != nil {
			return nil, err
		}
		messages = append(messages, appMessage)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate messages: %w", err)
	}

	attachmentLookup, err := store.loadAttachmentsForMessages(ctx, workspaceID, collectAppMessageIDs(messages))
	if err != nil {
		return nil, err
	}
	for i := range messages {
		messages[i].Attachments = attachmentLookup[messages[i].ID]
	}
	if err := store.attachAppMessageReadDecorations(ctx, workspaceID, viewerMemberID, messages); err != nil {
		return nil, err
	}

	return messages, nil
}

func collectAppMessageIDs(messages []message.AppMessage) []string {
	if len(messages) == 0 {
		return nil
	}
	ids := make([]string, 0, len(messages))
	for _, appMessage := range messages {
		trimmed := strings.TrimSpace(appMessage.ID)
		if trimmed == "" {
			continue
		}
		ids = append(ids, trimmed)
	}
	return ids
}

func CollectAppMessageIDs(messages []message.AppMessage) []string {
	return collectAppMessageIDs(messages)
}

func (store *Store) ResolveTaskCommandTargetForUser(ctx context.Context, userID string, workspaceID string, channelID string) (message.TaskCommandTarget, error) {
	_, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelPostAction, dmPostAction, threadPostAction)
	if err != nil {
		return message.TaskCommandTarget{}, err
	}
	return taskCommandTargetFromMessageTarget(target)
}

func (store *Store) ResolveTaskSourceTargetForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, targetRef string) (message.TaskCommandTarget, error) {
	trimmedTargetRef := strings.TrimSpace(targetRef)
	if trimmedTargetRef == "" {
		return message.TaskCommandTarget{}, badRequest("sourceTarget is required")
	}

	tx, err := store.db.BeginTx(ctx, coredb.TxOptions{})
	if err != nil {
		return message.TaskCommandTarget{}, fmt.Errorf("begin resolve task source target tx: %w", err)
	}
	defer rollback(tx, ctx)

	target, err := store.resolveSendTarget(ctx, tx, authenticatedAgent, trimmedTargetRef, "")
	if err != nil {
		return message.TaskCommandTarget{}, err
	}
	if err := tx.Commit(ctx); err != nil {
		return message.TaskCommandTarget{}, fmt.Errorf("commit resolve task source target: %w", err)
	}
	return taskCommandTargetFromMessageTarget(target)
}

// ResolveTaskTargetForAgent resolves one canonical agent-runtime target. Raw
// task scopes are authorized through the canonical Task->board Channel
// authorizer (ResolveTaskForAgentTaskScopeTarget); channel and reply-thread
// targets retain the existing agent message-target authorization path.
func (store *Store) ResolveTaskTargetForAgent(ctx context.Context, authenticatedAgent agent.AuthenticatedAgent, targetRef string) (message.TaskCommandTarget, error) {
	request, err := parseAgentScopeTargetRequest(targetRef)
	if err != nil {
		return message.TaskCommandTarget{}, err
	}
	if request.IsRawTask {
		task, err := store.ResolveTaskForAgentTaskScopeTarget(ctx, authenticatedAgent, request.TaskScopeID)
		if err != nil {
			return message.TaskCommandTarget{}, err
		}
		return message.TaskCommandTarget{
			TaskChannelID: task.ChannelID,
			TaskID:        task.TaskID,
			TaskScopeID:   task.TaskScopeID,
			TaskNumber:    task.TaskNumber,
		}, nil
	}
	return store.ResolveTaskSourceTargetForAgent(ctx, authenticatedAgent, request.Raw)
}

func taskCommandTargetFromMessageTarget(target message.ThreadAccess) (message.TaskCommandTarget, error) {
	if target.ReplyThread == nil {
		if target.ThreadType != domain.ThreadTypeChannel {
			return message.TaskCommandTarget{}, badRequest("tasks are only supported in channels")
		}
		trimmedChannelID := strings.TrimSpace(target.ChannelID)
		return message.TaskCommandTarget{
			TaskChannelID:    trimmedChannelID,
			TaskChannelRef:   channelRefFromName(target.ChannelName),
			MessageChannelID: trimmedChannelID,
		}, nil
	}

	if target.ThreadType != domain.ThreadTypeChannel {
		return message.TaskCommandTarget{}, badRequest("tasks are only supported in channels")
	}

	messageChannelID := strings.TrimSpace(target.ReplyThread.ParentChannelID)
	if messageChannelID == "" {
		return message.TaskCommandTarget{}, fmt.Errorf("reply thread parent channel is required")
	}

	sourceThreadShortID := strings.TrimSpace(target.ReplyThread.ShortID)
	sourceThreadRef := ""
	parentChannelName := strings.TrimSpace(target.ReplyThread.ParentChannelName)
	if parentChannelName != "" && sourceThreadShortID != "" {
		sourceThreadRef = fmt.Sprintf("#%s:%s", parentChannelName, sourceThreadShortID)
	}

	return message.TaskCommandTarget{
		TaskChannelID:               messageChannelID,
		TaskChannelRef:              channelRefFromName(parentChannelName),
		MessageChannelID:            messageChannelID,
		SourceThreadChannelID:       strings.TrimSpace(target.ChannelID),
		SourceThreadRef:             sourceThreadRef,
		SourceThreadParentChannelID: messageChannelID,
		SourceThreadParentMessageID: strings.TrimSpace(target.ReplyThread.ParentMessageID),
	}, nil
}

func channelRefFromName(channelName string) string {
	trimmed := strings.TrimSpace(channelName)
	if trimmed == "" {
		return ""
	}
	return "#" + trimmed
}

func (store *Store) ResolveChannelAgentMentionForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentName string) (message.AgentRecipient, error) {
	membership, _, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelPostAction, dmPostAction, threadPostAction)
	if err != nil {
		return message.AgentRecipient{}, err
	}

	return resolveVisibleChannelAgentRecipientByName(
		ctx,
		store.db,
		workspaceID,
		channelID,
		agentName,
		membership.Member.ID,
		membership.Member.ID,
	)
}

func (store *Store) ResolveChannelAgentRecipientByIDForUser(ctx context.Context, userID string, workspaceID string, channelID string, agentID string) (message.AgentRecipient, error) {
	membership, target, err := store.resolveAuthorizedHumanMessageTarget(ctx, userID, workspaceID, channelID, channelPostAction, dmPostAction, threadPostAction)
	if err != nil {
		return message.AgentRecipient{}, err
	}

	resolvedChannelID := strings.TrimSpace(channelID)
	if target.ReplyThread != nil {
		resolvedChannelID = strings.TrimSpace(target.ReplyThread.ParentChannelID)
	}
	return resolveVisibleChannelAgentRecipientByID(ctx, store.db, workspaceID, resolvedChannelID, agentID, membership.Member.ID, membership.Member.ID)
}

type mentionHintAgentRouting struct {
	recipients        []message.AgentRecipient
	privateRecipients []message.AgentRecipient
	// claimedTokens are all direct mention tokens selected as explicit agent
	// hints. They are removed from live name-based fallback even when the
	// hinted agent is no longer visible, avoiding accidental wake-up of a new
	// agent that later reuses the same display token.
	claimedTokens []string
}

func resolveMentionHintAgentRecipientsForUser(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string, viewerMemberID string, hints []message.MessageMentionHint, directMentionTokens []string, resolvedMentionLinks []message.ResolvedMentionLink) (mentionHintAgentRouting, error) {
	if len(hints) == 0 || len(directMentionTokens) == 0 {
		return mentionHintAgentRouting{}, nil
	}

	allowedTokens := make([]string, 0, len(directMentionTokens))
	seenAllowedTokens := make(map[string]struct{}, len(directMentionTokens))
	for _, token := range directMentionTokens {
		normalizedToken := strings.ToLower(strings.TrimSpace(token))
		if normalizedToken == "" {
			continue
		}
		if _, ok := seenAllowedTokens[normalizedToken]; ok {
			continue
		}
		seenAllowedTokens[normalizedToken] = struct{}{}
		allowedTokens = append(allowedTokens, normalizedToken)
	}
	if len(allowedTokens) == 0 {
		return mentionHintAgentRouting{}, nil
	}

	canonicalAgentIDsByToken := resolvedAgentMentionIDsByToken(resolvedMentionLinks, allowedTokens)
	agentIDs := make([]string, 0, len(hints))
	hintTokenByAgentID := make(map[string]string, len(hints))
	seenAgentIDs := make(map[string]struct{}, len(hints))
	seenHintTokens := make(map[string]struct{}, len(hints))
	claimedTokens := make([]string, 0, len(hints))
	seenClaimedTokens := make(map[string]struct{}, len(hints))
	for _, hint := range hints {
		if strings.ToLower(strings.TrimSpace(hint.Type)) != "agent" {
			continue
		}
		agentID := strings.TrimSpace(hint.ID)
		if agentID == "" {
			continue
		}
		normalizedToken := strings.ToLower(strings.TrimSpace(hint.Token))
		if !mentionTokenMatchesAnyDirectToken(normalizedToken, allowedTokens) {
			continue
		}
		if _, exists := seenClaimedTokens[normalizedToken]; !exists {
			seenClaimedTokens[normalizedToken] = struct{}{}
			claimedTokens = append(claimedTokens, normalizedToken)
		}
		if _, exists := seenAgentIDs[agentID]; exists {
			continue
		}
		if canonicalAgentIDs := canonicalAgentIDsByToken[normalizedToken]; len(canonicalAgentIDs) > 0 {
			if _, exists := canonicalAgentIDs[agentID]; !exists {
				continue
			}
		} else {
			if _, exists := seenHintTokens[normalizedToken]; exists {
				continue
			}
			seenHintTokens[normalizedToken] = struct{}{}
		}
		hintTokenByAgentID[agentID] = normalizedToken

		seenAgentIDs[agentID] = struct{}{}
		agentIDs = append(agentIDs, agentID)
		if len(agentIDs) >= message.MaxPersistedMentionHints {
			break
		}
	}
	if len(agentIDs) == 0 {
		return mentionHintAgentRouting{claimedTokens: claimedTokens}, nil
	}

	requestedAgentsCTE, requestedAgentArgs, _, err := coredb.BuildStringValuesCTE("requested_agents", "agent_id", agentIDs, 5)
	if err != nil {
		return mentionHintAgentRouting{}, err
	}
	rows, err := queryer.Query(ctx, fmt.Sprintf(`
WITH %s
SELECT
    a.id,
    a.machine_id,
    a.member_id,
    CASE
        WHEN sm.visibility = 'private' THEN 1
        ELSE 0
    END AS private_visibility,
    CASE
        WHEN sns.state = 'auto-wake' THEN 1
        ELSE 0
    END AS auto_wake_enabled
FROM requested_agents
JOIN agents a
  ON a.workspace_id = $2
 AND a.id = requested_agents.agent_id
 AND a.status <> 'archived'
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = $1
 AND sm.member_id = a.member_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
LEFT JOIN scope_notification_settings sns
  ON sns.scope_id = sm.scope_id
 AND sns.member_id = sm.member_id
WHERE a.member_id <> $3
  AND (
      sm.visibility = 'public'
      OR (sm.visibility = 'private' AND sm.owner_member_id = $4)
  )
ORDER BY a.name ASC, a.member_id ASC, CASE WHEN sm.visibility = 'private' THEN 0 ELSE 1 END, sm.effective_from_seq DESC
`, requestedAgentsCTE), append([]any{strings.TrimSpace(channelID), strings.TrimSpace(workspaceID), strings.TrimSpace(viewerMemberID), strings.TrimSpace(viewerMemberID)}, requestedAgentArgs...)...)
	if err != nil {
		return mentionHintAgentRouting{}, fmt.Errorf("resolve mention hint agent recipients: %w", err)
	}
	defer rows.Close()

	recipients := make([]message.AgentRecipient, 0, len(agentIDs))
	privateRecipients := make([]message.AgentRecipient, 0, len(agentIDs))
	for rows.Next() {
		var recipient message.AgentRecipient
		var privateVisibility bool
		if err := rows.Scan(&recipient.AgentID, &recipient.MachineID, &recipient.AgentMemberID, &privateVisibility, &recipient.AutoWakeEnabled); err != nil {
			return mentionHintAgentRouting{}, fmt.Errorf("scan mention hint agent recipient: %w", err)
		}
		recipient.DeliveryReason = message.AgentDeliveryReasonDirectMention
		recipient.MentionToken = hintTokenByAgentID[strings.TrimSpace(recipient.AgentID)]
		recipients = append(recipients, recipient)
		if privateVisibility {
			privateRecipients = append(privateRecipients, recipient)
		}
	}
	if err := rows.Err(); err != nil {
		return mentionHintAgentRouting{}, fmt.Errorf("iterate mention hint agent recipients: %w", err)
	}

	return mentionHintAgentRouting{recipients: recipients, privateRecipients: privateRecipients, claimedTokens: claimedTokens}, nil
}

func resolvedAgentMentionIDsByToken(links []message.ResolvedMentionLink, allowedTokens []string) map[string]map[string]struct{} {
	if len(links) == 0 || len(allowedTokens) == 0 {
		return nil
	}
	byToken := make(map[string]map[string]struct{}, len(links))
	for _, link := range links {
		if strings.ToLower(strings.TrimSpace(link.Type)) != "agent" {
			continue
		}
		agentID := strings.TrimSpace(link.ID)
		token := strings.ToLower(strings.TrimSpace(link.Token))
		if agentID == "" || token == "" || !mentionTokenMatchesAnyDirectToken(token, allowedTokens) {
			continue
		}
		if byToken[token] == nil {
			byToken[token] = make(map[string]struct{})
		}
		byToken[token][agentID] = struct{}{}
	}
	return byToken
}

type teamMentionExpander interface {
	ExpandTeamMentionsForScope(context.Context, team.ExpandTeamMentionsParams) ([]team.TeamExpansionEdge, error)
}

type teamMentionRouting struct {
	recipients              []message.AgentRecipient
	tokens                  []string
	mentions                []message.PersistedMention
	suppressDefaultAutoWake bool
}

func resolveTeamMentionRecipientsForUser(ctx context.Context, expander teamMentionExpander, workspaceID string, scopeID string, authorMemberID string, hints []message.MessageMentionHint, directMentionTokens []string, fallbackTokens []string) (teamMentionRouting, error) {
	if expander == nil || len(directMentionTokens) == 0 {
		return teamMentionRouting{}, nil
	}
	teamHints := make([]team.TeamMentionHint, 0, len(hints))
	for _, hint := range hints {
		if strings.ToLower(strings.TrimSpace(hint.Type)) != "team" {
			continue
		}
		teamHints = append(teamHints, team.TeamMentionHint{Type: hint.Type, ID: hint.ID, Token: hint.Token})
	}
	edges, err := expander.ExpandTeamMentionsForScope(ctx, team.ExpandTeamMentionsParams{
		WorkspaceID: workspaceID, ScopeID: scopeID, AuthorMemberID: authorMemberID,
		Tokens: directMentionTokens, FallbackTokens: fallbackTokens, Hints: teamHints,
	})
	if err != nil {
		return teamMentionRouting{}, err
	}
	if len(edges) == 0 {
		return teamMentionRouting{}, nil
	}

	rosterByTeam := make(map[string][]message.TeamDeliveryRosterMember)
	for _, edge := range edges {
		teamID := strings.TrimSpace(edge.TeamID)
		if teamID == "" {
			continue
		}
		rosterByTeam[teamID] = append(rosterByTeam[teamID], message.TeamDeliveryRosterMember{
			TeamMemberID: edge.TeamMemberID, ActorKind: edge.ActorKind, ActorID: edge.ActorID,
			DisplayName: edge.ActorDisplayName, Handle: edge.ActorHandle, Role: edge.Role,
			RoleLabel: edge.RoleLabel, RoleOverlayPrompt: edge.RoleOverlaySnapshot,
			WakeOnTeamMention: edge.WakeOnTeamMention, DeliveryKind: edge.ActorKind,
		})
	}

	recipients := make([]message.AgentRecipient, 0)
	mentions := make([]message.PersistedMention, 0)
	tokens := make([]string, 0)
	seenMentions := make(map[string]struct{})
	for _, edge := range edges {
		teamID := strings.TrimSpace(edge.TeamID)
		mentionToken := strings.ToLower(strings.TrimSpace(edge.MentionToken))
		if teamID != "" && mentionToken != "" {
			key := teamID + "\x00" + mentionToken
			if _, ok := seenMentions[key]; !ok {
				seenMentions[key] = struct{}{}
				mentions = append(mentions, message.PersistedMention{Type: "team", ID: teamID, Token: mentionToken})
				tokens = append(tokens, mentionToken)
			}
		}
		if edge.ActorKind != team.ActorKindAgent || !edge.WakeOnTeamMention {
			continue
		}
		agentMemberID := strings.TrimSpace(edge.ActorID)
		if strings.TrimSpace(edge.AgentID) == "" || agentMemberID == "" {
			continue
		}
		recipients = append(recipients, message.AgentRecipient{
			AgentID: edge.AgentID, MachineID: edge.MachineID, AgentMemberID: agentMemberID,
			AutoWakeEnabled: edge.AutoWakeEnabled, DeliveryReason: message.AgentDeliveryReasonTeamMention,
			MentionToken: mentionToken,
			TeamContexts: []message.TeamDeliveryContext{{
				TeamID: teamID, TeamHandle: edge.TeamHandle, TeamDisplayName: edge.TeamDisplayName,
				TeamDescription: edge.TeamDescription, TeamMemberID: edge.TeamMemberID, Role: edge.Role,
				RoleLabel: edge.RoleLabel, RoleOverlayPromptSnapshot: edge.RoleOverlaySnapshot,
				RoleOverlayVersion: edge.RoleOverlayVersion, MemberRoster: rosterWithSelf(rosterByTeam[teamID], agentMemberID),
			}},
		})
	}
	return teamMentionRouting{
		recipients: message.MergeAgentRecipients(recipients), tokens: dedupeNormalizedTokens(tokens),
		mentions: mentions, suppressDefaultAutoWake: true,
	}, nil
}

func rosterWithSelf(roster []message.TeamDeliveryRosterMember, actorID string) []message.TeamDeliveryRosterMember {
	if len(roster) == 0 {
		return nil
	}
	cloned := make([]message.TeamDeliveryRosterMember, 0, len(roster))
	for _, member := range roster {
		member.Self = strings.TrimSpace(member.ActorID) == strings.TrimSpace(actorID)
		cloned = append(cloned, member)
	}
	return cloned
}

func dedupeNormalizedTokens(tokens []string) []string {
	if len(tokens) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(tokens))
	normalized := make([]string, 0, len(tokens))
	for _, token := range tokens {
		value := strings.ToLower(strings.TrimSpace(token))
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		normalized = append(normalized, value)
	}
	return normalized
}

func mentionTokenMatchesAnyDirectToken(target string, directTokens []string) bool {
	for _, token := range directTokens {
		if message.MentionTokenMatchesTarget(token, target) {
			return true
		}
	}
	return false
}

func (store *Store) ResolveChannelAgentRecipientByIDForAgent(ctx context.Context, authorAgent agent.AuthenticatedAgent, channel string, agentID string) (message.AgentRecipient, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.AgentRecipient, error) {
		target, err := store.resolveSendTarget(ctx, tx, authorAgent, channel, "")
		if err != nil {
			return message.AgentRecipient{}, err
		}
		channelID := strings.TrimSpace(target.ChannelID)
		if target.ReplyThread != nil {
			channelID = strings.TrimSpace(target.ReplyThread.ParentChannelID)
		}
		viewerMemberID, err := loadChannelRoutingViewerMemberID(ctx, tx, channelID, authorAgent.MemberID)
		if err != nil {
			return message.AgentRecipient{}, err
		}
		return resolveVisibleChannelAgentRecipientByID(
			ctx,
			tx,
			authorAgent.WorkspaceID,
			channelID,
			agentID,
			authorAgent.MemberID,
			viewerMemberID,
		)
	})
}

func (store *Store) ResolveChannelAgentMentionForAgent(ctx context.Context, authorAgent agent.AuthenticatedAgent, channel string, agentName string) (message.AgentRecipient, error) {
	return withTxValue(ctx, store.db, func(tx coredb.Tx) (message.AgentRecipient, error) {
		target, err := store.resolveSendTarget(ctx, tx, authorAgent, channel, "")
		if err != nil {
			return message.AgentRecipient{}, err
		}
		channelID := strings.TrimSpace(target.ChannelID)
		if target.ReplyThread != nil {
			channelID = strings.TrimSpace(target.ReplyThread.ParentChannelID)
		}
		viewerMemberID, err := loadChannelRoutingViewerMemberID(ctx, tx, channelID, authorAgent.MemberID)
		if err != nil {
			return message.AgentRecipient{}, err
		}
		return resolveVisibleChannelAgentRecipientByName(
			ctx,
			tx,
			authorAgent.WorkspaceID,
			channelID,
			agentName,
			authorAgent.MemberID,
			viewerMemberID,
		)
	})
}

func resolveVisibleChannelAgentRecipientByID(ctx context.Context, queryer coredb.QueryRower, workspaceID string, channelID string, agentID string, authorMemberID string, viewerMemberID string) (message.AgentRecipient, error) {
	row := queryer.QueryRow(ctx, `
SELECT
    a.id,
    a.machine_id,
    a.member_id,
    CASE
        WHEN sns.state = 'auto-wake' THEN 1
        ELSE 0
    END AS auto_wake_enabled
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = $1
 AND sm.member_id = a.member_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
LEFT JOIN scope_notification_settings sns
  ON sns.scope_id = sm.scope_id
 AND sns.member_id = sm.member_id
WHERE a.workspace_id = $2
  AND a.id = $3
  AND a.status <> 'archived'
  AND a.member_id <> $4
  AND (
      sm.visibility = 'public'
      OR (sm.visibility = 'private' AND sm.owner_member_id = $5)
  )
ORDER BY CASE WHEN sm.visibility = 'private' THEN 0 ELSE 1 END, sm.effective_from_seq DESC
LIMIT 1
`, strings.TrimSpace(channelID), strings.TrimSpace(workspaceID), strings.TrimSpace(agentID), strings.TrimSpace(authorMemberID), strings.TrimSpace(viewerMemberID))

	recipient, err := scanVisibleChannelAgentRecipient(row, "mentionedAgentIds must reference channel agents", "resolve agent recipient by id")
	if err != nil {
		return message.AgentRecipient{}, err
	}
	return recipient, nil
}

// resolveVisibleChannelAgentRecipientByRef resolves the Workflow DSL's
// AgentRef representation without changing the representation accepted by
// ordinary target/mention APIs. Invocation forms currently use a workspace
// Agent name, while internal callers and defaults may carry an Agent ID or
// member ID. Canonical Agent IDs win the deliberately deterministic fallback
// order if a legacy name happens to collide with another Agent's identifier.
func resolveVisibleChannelAgentRecipientByRef(ctx context.Context, queryer coredb.Queryer, workspaceID string, channelID string, agentRef string, authorMemberID string, viewerMemberID string) (message.AgentRecipient, error) {
	trimmedAgentRef := strings.TrimSpace(agentRef)
	lookupAgentRef := strings.TrimPrefix(trimmedAgentRef, "@")
	rows, err := queryer.Query(ctx, `
SELECT
    a.id,
    a.machine_id,
    a.member_id,
	 a.name,
    CASE
        WHEN sns.state = 'auto-wake' THEN 1
        ELSE 0
    END AS auto_wake_enabled
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = $1
 AND sm.member_id = a.member_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
LEFT JOIN scope_notification_settings sns
  ON sns.scope_id = sm.scope_id
 AND sns.member_id = sm.member_id
WHERE a.workspace_id = $2
  AND a.status <> 'archived'
  AND a.member_id <> $4
  AND (
      a.id = $3
      OR LOWER(a.member_id) = LOWER($3)
      OR LOWER(a.name) = LOWER($3)
  )
  AND (
      sm.visibility = 'public'
      OR (sm.visibility = 'private' AND sm.owner_member_id = $5)
  )
ORDER BY
    CASE
        WHEN a.id = $3 THEN 0
        WHEN LOWER(a.member_id) = LOWER($3) THEN 1
        ELSE 2
    END,
    CASE WHEN sm.visibility = 'private' THEN 0 ELSE 1 END,
    sm.effective_from_seq DESC
`,
		strings.TrimSpace(channelID),
		strings.TrimSpace(workspaceID),
		lookupAgentRef,
		strings.TrimSpace(authorMemberID),
		strings.TrimSpace(viewerMemberID),
	)
	if err != nil {
		return message.AgentRecipient{}, fmt.Errorf(
			"resolve workflow callback responder by ref: %w",
			err,
		)
	}
	defer rows.Close()

	return scanUniqueWorkflowCallbackResponder(
		rows,
		fmt.Sprintf(
			"workflow callback responder %q is not visible in this scope",
			trimmedAgentRef,
		),
	)
}

// resolveWorkflowCallbackDMResponderByRef selects the structured callback
// authority from the same peer/participant set used by ordinary DM routing.
// A DM does not expose channel membership rows under target.ChannelID, so the
// channel resolver cannot be reused. Selection only materializes the visible
// resolved mention; delivery and wake still run through the ordinary DM plan.
func resolveWorkflowCallbackDMResponderByRef(
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	target message.ThreadAccess,
	agentRef string,
	authorMemberID string,
) (message.AgentRecipient, error) {
	trimmedAgentRef := strings.TrimSpace(agentRef)
	lookupAgentRef := strings.TrimPrefix(trimmedAgentRef, "@")
	if target.ReplyThread != nil {
		// Reply-thread routing owns this projection for ordinary messages too,
		// but it is normally synchronized after the first reply is persisted.
		// Initialize the parent DM peer before planning this first visible mention
		// so the current message, not only later replies, follows ordinary routing.
		if err := message.EnsureReplyThreadParentDMAgentParticipant(
			ctx,
			tx,
			message.ScopeIDForThreadAccess(target),
			time.Now().UTC(),
		); err != nil {
			return message.AgentRecipient{}, err
		}
	}
	plan, err := message.LoadScopeAgentRoutingPlan(ctx, tx, authorMemberID, target, "", false)
	if err != nil {
		return message.AgentRecipient{}, err
	}
	candidates := message.MergeAgentRecipients(plan.DurableRecipients, plan.WakeRecipients)
	ranked := make([][]message.AgentRecipient, 3)
	for _, candidate := range candidates {
		var mentionToken string
		if err := tx.QueryRow(ctx, `
SELECT name
FROM agents
WHERE workspace_id = $1
  AND id = $2
  AND status <> 'archived'
LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(candidate.AgentID)).Scan(&mentionToken); err != nil {
			if coredb.IsNotFound(err) {
				continue
			}
			return message.AgentRecipient{}, fmt.Errorf("load workflow callback DM responder identity: %w", err)
		}
		candidate.MentionToken = strings.TrimSpace(mentionToken)
		rank := -1
		switch {
		case strings.TrimSpace(candidate.AgentID) == lookupAgentRef:
			rank = 0
		case strings.EqualFold(strings.TrimSpace(candidate.AgentMemberID), lookupAgentRef):
			rank = 1
		case strings.EqualFold(candidate.MentionToken, lookupAgentRef):
			rank = 2
		}
		if rank >= 0 {
			ranked[rank] = append(ranked[rank], candidate)
		}
	}
	for _, matches := range ranked {
		if len(matches) == 0 {
			continue
		}
		if len(matches) > 1 {
			return message.AgentRecipient{}, badRequest("workflow callback responder is ambiguous; use an exact Agent ID")
		}
		return matches[0], nil
	}
	return message.AgentRecipient{}, badRequest(fmt.Sprintf(
		"workflow callback responder %q is not visible in this scope",
		trimmedAgentRef,
	))
}

func scanUniqueWorkflowCallbackResponder(
	rows coredb.Rows,
	notFoundMessage string,
) (message.AgentRecipient, error) {
	matches := make(map[string]message.AgentRecipient)
	for rows.Next() {
		var recipient message.AgentRecipient
		if err := rows.Scan(
			&recipient.AgentID,
			&recipient.MachineID,
			&recipient.AgentMemberID,
			&recipient.MentionToken,
			&recipient.AutoWakeEnabled,
		); err != nil {
			return message.AgentRecipient{}, fmt.Errorf(
				"scan workflow callback responder: %w",
				err,
			)
		}
		agentID := strings.TrimSpace(recipient.AgentID)
		if agentID == "" {
			continue
		}
		recipient.DeliveryReason = message.AgentDeliveryReasonTaskCommandNotify
		if _, exists := matches[agentID]; !exists {
			matches[agentID] = recipient
		}
	}
	if err := rows.Err(); err != nil {
		return message.AgentRecipient{}, fmt.Errorf(
			"iterate workflow callback responders: %w",
			err,
		)
	}
	if len(matches) == 0 {
		return message.AgentRecipient{}, badRequest(notFoundMessage)
	}
	if len(matches) > 1 {
		return message.AgentRecipient{}, badRequest(
			"workflow callback responder is ambiguous; use an exact Agent ID",
		)
	}
	for _, recipient := range matches {
		return recipient, nil
	}
	return message.AgentRecipient{}, badRequest(notFoundMessage)
}

func materializeWorkflowCallbackResponderMention(content string, recipient message.AgentRecipient) (string, message.MessageMentionHint, error) {
	agentID := strings.TrimSpace(recipient.AgentID)
	token := strings.TrimPrefix(strings.TrimSpace(recipient.MentionToken), "@")
	if agentID == "" || token == "" {
		return "", message.MessageMentionHint{}, badRequest("workflow callback responder has no resolved mention identity")
	}
	hint := message.MessageMentionHint{Type: "agent", ID: agentID, Token: token}
	for _, link := range message.ParseResolvedMentionLinks(content) {
		if strings.EqualFold(strings.TrimSpace(link.Type), "agent") && strings.TrimSpace(link.ID) == agentID {
			return strings.TrimSpace(content), hint, nil
		}
	}
	rendered, err := routing.FormatResolvedMentionLink(routing.ResolvedMentionLink{
		Type: "agent", ID: agentID, Token: token,
	})
	if err != nil {
		return "", message.MessageMentionHint{}, badRequest(err.Error())
	}
	content = strings.TrimSpace(content)
	if content == "" {
		return rendered, hint, nil
	}
	return rendered + " " + content, hint, nil
}

// materializeWorkflowRecoveryResponderPresentation copies the resolver-owned
// Agent/token identity into the closed recovery presentation after callback
// routing has resolved the frozen responder. Any authored responder mention is
// deliberately replaced; only the transaction's canonical recipient is trusted.
func materializeWorkflowRecoveryResponderPresentation(metadata map[string]any, recipient message.AgentRecipient) error {
	if len(metadata) == 0 {
		return nil
	}
	presentation, err := domain.MessagePresentationFromMetadata(metadata)
	if err != nil {
		return badRequest(err.Error())
	}
	if presentation == nil || presentation.Key != domain.MessagePresentationKeyWorkflowRecovery {
		return nil
	}
	updated, err := presentation.WithWorkflowRecoveryResponderMention(
		strings.TrimSpace(recipient.AgentID),
		strings.TrimPrefix(strings.TrimSpace(recipient.MentionToken), "@"),
	)
	if err != nil {
		return badRequest(err.Error())
	}
	value, err := updated.MetadataValue()
	if err != nil {
		return badRequest(err.Error())
	}
	metadata[domain.MessagePresentationMetadataKey] = value
	return nil
}

func resolveVisibleChannelAgentRecipientByName(ctx context.Context, queryer coredb.QueryRower, workspaceID string, channelID string, agentName string, authorMemberID string, viewerMemberID string) (message.AgentRecipient, error) {
	trimmedAgentName := strings.TrimSpace(agentName)
	row := queryer.QueryRow(ctx, `
SELECT
    a.id,
    a.machine_id,
    a.member_id,
    CASE
        WHEN sns.state = 'auto-wake' THEN 1
        ELSE 0
    END AS auto_wake_enabled
FROM agents a
JOIN machines mac
  ON mac.workspace_id = a.workspace_id
 AND mac.id = a.machine_id
 AND mac.status = 'active'
JOIN scope_memberships sm
  ON sm.scope_id = $1
 AND sm.member_id = a.member_id
 AND sm.removed_at IS NULL
 AND sm.archived_at IS NULL
 AND sm.effective_to_seq IS NULL
LEFT JOIN scope_notification_settings sns
  ON sns.scope_id = sm.scope_id
 AND sns.member_id = sm.member_id
WHERE a.workspace_id = $2
  AND a.status <> 'archived'
  AND a.member_id <> $3
  AND (
      LOWER(a.name) = $4
      OR LOWER(a.member_id) = $4
  )
  AND (
      sm.visibility = 'public'
      OR (sm.visibility = 'private' AND sm.owner_member_id = $5)
  )
ORDER BY a.name ASC, a.member_id ASC, CASE WHEN sm.visibility = 'private' THEN 0 ELSE 1 END, sm.effective_from_seq DESC
LIMIT 1
`, strings.TrimSpace(channelID), strings.TrimSpace(workspaceID), strings.TrimSpace(authorMemberID), strings.ToLower(trimmedAgentName), strings.TrimSpace(viewerMemberID))

	recipient, err := scanVisibleChannelAgentRecipient(row, fmt.Sprintf("agent @%s is not in this channel", trimmedAgentName), "resolve channel agent mention")
	if err != nil {
		return message.AgentRecipient{}, err
	}
	return recipient, nil
}

func loadChannelRoutingViewerMemberID(ctx context.Context, tx coredb.Tx, channelID string, authorMemberID string) (string, error) {
	trimmedAuthorMemberID := strings.TrimSpace(authorMemberID)
	if trimmedAuthorMemberID == "" {
		return "", fmt.Errorf("author member id is required")
	}

	var hasPrivateEntry bool
	if err := tx.QueryRow(ctx, `
SELECT EXISTS (
    SELECT 1
    FROM scope_memberships sm
    JOIN agents a
      ON a.member_id = sm.member_id
    WHERE sm.scope_id = $1
      AND sm.member_id = $2
      AND a.status <> 'archived'
      AND sm.visibility = 'private'
      AND sm.removed_at IS NULL
      AND sm.archived_at IS NULL
      AND sm.effective_to_seq IS NULL
)
`, strings.TrimSpace(channelID), trimmedAuthorMemberID).Scan(&hasPrivateEntry); err != nil {
		return "", fmt.Errorf("load channel routing viewer member id: %w", err)
	}
	if hasPrivateEntry {
		return "", ErrForbidden
	}
	return trimmedAuthorMemberID, nil
}

func scanVisibleChannelAgentRecipient(row coredb.Row, notFoundMessage string, errorPrefix string) (message.AgentRecipient, error) {
	var recipient message.AgentRecipient
	if err := row.Scan(&recipient.AgentID, &recipient.MachineID, &recipient.AgentMemberID, &recipient.AutoWakeEnabled); err != nil {
		if coredb.IsNotFound(err) {
			return message.AgentRecipient{}, badRequest(notFoundMessage)
		}
		return message.AgentRecipient{}, fmt.Errorf("%s: %w", errorPrefix, err)
	}
	if strings.TrimSpace(recipient.AgentID) == "" {
		return message.AgentRecipient{}, badRequest(notFoundMessage)
	}
	recipient.DeliveryReason = message.AgentDeliveryReasonTaskCommandNotify
	return recipient, nil
}

func scanAppMessageRow(scanner interface {
	Scan(...any) error
}) (message.AppMessage, error) {
	return scanAppMessageRowWithExtra(scanner)
}

func scanAppMessageRowWithDigest(scanner interface {
	Scan(...any) error
}, digest *string) (message.AppMessage, error) {
	return scanAppMessageRowWithExtra(scanner, digest)
}

func scanAppMessageRowWithExtra(scanner interface {
	Scan(...any) error
}, extra ...any) (message.AppMessage, error) {
	var appMessage message.AppMessage
	var memberType string
	var authorMemberID string
	var userID sql.NullString
	var agentID sql.NullString
	var agentName sql.NullString
	var userDisplayName sql.NullString
	var memberDisplayName sql.NullString
	var userName sql.NullString
	var userAvatarURL sql.NullString
	var agentAvatarURL sql.NullString
	var clientMessageID sql.NullString
	var editedAt sql.NullTime
	var rawMetadata []byte
	destinations := []any{
		&appMessage.ID,
		&appMessage.Seq,
		&appMessage.ChannelID,
		&memberType,
		&authorMemberID,
		&userID,
		&agentID,
		&agentName,
		&userDisplayName,
		&memberDisplayName,
		&userName,
		&userAvatarURL,
		&agentAvatarURL,
		&clientMessageID,
		&appMessage.MessageType,
		&appMessage.Content,
		&rawMetadata,
		&appMessage.CreatedAt,
		&editedAt,
		&appMessage.EditedByMemberID,
		&appMessage.EditRevision,
	}
	destinations = append(destinations, extra...)
	if err := scanner.Scan(destinations...); err != nil {
		if coredb.IsNotFound(err) {
			return message.AppMessage{}, ErrNotFound
		}
		return message.AppMessage{}, fmt.Errorf("scan message: %w", err)
	}
	appMessage.ClientMessageID = trimmedNullString(clientMessageID)
	appMessage.EditedAt = ptrFromSQLTime(editedAt)
	authorMemberID = strings.TrimSpace(authorMemberID)

	switch memberType {
	case "human":
		appMessage.ApplyAuthorProfile(message.AuthorProfile{
			ActorType:   "human",
			PrincipalID: trimmedNullString(userID),
			MemberID:    authorMemberID,
			TargetID:    authorMemberID,
			DisplayName: preferredDisplayName(
				trimmedNullString(userDisplayName),
				trimmedNullString(memberDisplayName),
				trimmedNullString(userName),
				authorMemberID,
			),
			AvatarURL: trimmedNullString(userAvatarURL),
		})
	case "agent":
		agentNameValue := trimmedNullString(agentName)
		appMessage.ApplyAuthorProfile(message.AuthorProfile{
			ActorType:   "agent",
			PrincipalID: trimmedNullString(agentID),
			MemberID:    authorMemberID,
			TargetID:    agentNameValue,
			DisplayName: preferredDisplayName(
				trimmedNullString(memberDisplayName),
				agentNameValue,
				authorMemberID,
			),
			AvatarURL: trimmedNullString(agentAvatarURL),
		})
	case "service":
		appMessage.ApplyAuthorProfile(message.AuthorProfile{
			ActorType:   "service",
			PrincipalID: authorMemberID,
			MemberID:    authorMemberID,
			TargetID:    authorMemberID,
			DisplayName: preferredDisplayName(trimmedNullString(memberDisplayName), authorMemberID),
		})
	default:
		return message.AppMessage{}, fmt.Errorf("invalid author type %q", memberType)
	}
	metadata, err := message.DecodeMessageMetadata(rawMetadata)
	if err != nil {
		return message.AppMessage{}, err
	}
	appMessage.Metadata = metadata
	message.ApplyDefaultContributionSourceToAppMessage(&appMessage)

	return appMessage, nil
}

func workflowMessageScopeTypeTx(ctx context.Context, tx coredb.Queryer, workspaceID, scopeID string) (string, error) {
	var scopeType string
	if err := tx.QueryRow(ctx, `
SELECT type FROM scopes WHERE workspace_id=$1 AND id=$2 LIMIT 1
`, strings.TrimSpace(workspaceID), strings.TrimSpace(scopeID)).Scan(&scopeType); err != nil {
		if coredb.IsNotFound(err) {
			return "", ErrNotFound
		}
		return "", fmt.Errorf("detect workflow message scope type: %w", err)
	}
	return strings.TrimSpace(scopeType), nil
}

func writeWorkflowTaskMessageInTx(
	store *Store,
	ctx context.Context,
	tx coredb.Tx,
	user model.User,
	workspaceID, taskScopeID, content string,
	options SendAppMessageOptions,
) (message.AppMessage, message.MessageDelivery, error) {
	prepared, err := store.prepareTaskScopeHumanMessageTx(ctx, tx, user, workspaceID, taskScopeID, content, nil, options)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, mapTaskScopeMessageError(err)
	}
	appMessage, delivery, moderationBlock, _, err := store.appendTaskScopeHumanMessageTx(ctx, tx, user, prepared)
	if err != nil {
		if moderationBlock != nil && errors.Is(err, message.ErrContentModerationBlocked) {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
		return message.AppMessage{}, message.MessageDelivery{}, mapTaskScopeMessageError(err)
	}
	return appMessage, delivery, nil
}

func workflowApprovalMessageReplay(
	ctx context.Context,
	tx coredb.Queryer,
	workspaceID string,
	prepared preparedUserAppMessage,
	options SendAppMessageOptions,
	replay appMessageReplayCandidate,
) (message.AppMessage, message.MessageDelivery, error) {
	if err := validateClientMessageIDReplaySideEffect(replay.startedAgentThread, options); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	requestedDigest, err := appMessageCreateDigest(prepared, options)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	if !strings.EqualFold(replay.idempotencyPayloadDigest, requestedDigest) {
		return message.AppMessage{}, message.MessageDelivery{}, conflict("clientMessageId was already used with a different message payload")
	}
	existing := replay.message
	if !options.Source.IsZero() {
		existing.Source = options.Source.Normalize()
	}
	message.ApplyAutomationServiceAuthorToAppMessage(&existing)
	delivery := message.MessageDelivery{
		MessageID:        existing.ID,
		GlobalSeq:        existing.Seq,
		ClientMessageID:  existing.ClientMessageID,
		InterruptPolicy:  strings.TrimSpace(options.InterruptPolicy),
		IdempotentReplay: true,
		AppMessage:       &existing,
	}
	if options.StartAgentThread {
		replyThread, err := loadStartedReplyThreadReplay(ctx, tx, prepared, strings.TrimSpace(workspaceID), existing.ID)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
		delivery.StartedReplyThread = &replyThread
	}
	return existing, delivery, nil
}

func appendWorkflowApprovalMessageTx(
	store *Store,
	ctx context.Context,
	tx coredb.Tx,
	user model.User,
	workspaceID string,
	prepared preparedUserAppMessage,
	options SendAppMessageOptions,
	clientMessageID string,
) (message.AppMessage, message.MessageDelivery, error) {
	if prepared.workflowCallbackResponder != nil && prepared.callbackTaskWatchHandoff != nil {
		if err := store.applyWorkflowCallbackTaskWatchHandoffTx(
			ctx, tx, strings.TrimSpace(workspaceID), *prepared.workflowCallbackResponder,
			*prepared.callbackTaskWatchHandoff, time.Now().UTC(),
		); err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
	}
	idempotencyPayloadDigest := ""
	if clientMessageID != "" {
		var err error
		idempotencyPayloadDigest, err = appMessageCreateDigest(prepared, options)
		if err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
	}
	delivery, err := store.appendMessageToThread(
		ctx, tx, prepared.membership.Member.ID, domain.MemberTypeHuman, prepared.target,
		prepared.trimmedContent, prepared.normalizedAttachmentIDs,
		appendMessageOptions{
			AllowControlledBroadcast:     prepared.allowControlledBroadcast,
			AllowWorkflowContextMetadata: options.AllowWorkflowContextMetadata,
			MentionHints:                 options.MentionHints,
			MessageType:                  prepared.normalizedMessageType,
			ExplicitRoutingPlan:          prepared.explicitRoutingPlan,
			Metadata:                     prepared.metadata,
			Source:                       options.Source,
			RoutingPrincipalMemberID:     prepared.routingPrincipalMemberID,
			ClientMessageID:              clientMessageID,
			IdempotencyPayloadDigest:     idempotencyPayloadDigest,
			InterruptPolicy:              options.InterruptPolicy,
			OwnerQuoteSourceMessageIDs:   options.OwnerQuoteSourceMessageIDs,
		}, messageAppendCallerOwned,
	)
	if err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	if err := store.bindScheduleRunMessageSource(ctx, tx, workspaceID, options.Source, delivery.MessageID, delivery.Message.Timestamp); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	if err := store.bindWorkflowRunMessageSource(ctx, tx, workspaceID, options.Source, delivery.MessageID, clientMessageID, delivery.Message.Timestamp); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	if options.StartAgentThread {
		if err := startWorkflowApprovalReplyThread(store, ctx, tx, workspaceID, prepared, &delivery); err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
	}
	if strings.TrimSpace(prepared.liveRoutingContent) != "" {
		if _, err := store.handleAppMentionDeliveriesForMessage(
			ctx, tx, prepared.membership.Member.ID, string(domain.MemberTypeHuman), workspaceID,
			prepared.target, delivery.MessageID, prepared.normalizedMessageType, prepared.trimmedContent,
		); err != nil {
			return message.AppMessage{}, message.MessageDelivery{}, err
		}
	}
	appMessage := appMessageFromDelivery(
		appChannelIDForTarget(prepared.target),
		humanAuthorProfileFromUser(user, prepared.membership.Member.ID, prepared.membership.Member.DisplayName),
		delivery,
	)
	if !delivery.Message.Source.IsZero() {
		appMessage.Source = delivery.Message.Source.Normalize()
	} else if !options.Source.IsZero() {
		appMessage.Source = options.Source.Normalize()
	}
	message.ApplyAutomationServiceAuthorToAppMessage(&appMessage)
	if err := attachResolvedMentionsForSentAppMessage(ctx, tx, workspaceID, &appMessage); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	attachAppMessageToDelivery(&delivery, &appMessage)
	if err := store.persistScopeFanoutPayloads(ctx, tx, delivery); err != nil {
		return message.AppMessage{}, message.MessageDelivery{}, err
	}
	return appMessage, delivery, nil
}

func startWorkflowApprovalReplyThread(
	store *Store,
	ctx context.Context,
	tx coredb.Tx,
	workspaceID string,
	prepared preparedUserAppMessage,
	delivery *message.MessageDelivery,
) error {
	replyThread, err := message.OpenReplyThreadForMember(ctx, tx, prepared.membership.Member.ID, strings.TrimSpace(workspaceID), strings.TrimSpace(prepared.target.ChannelID), delivery.MessageID)
	if err != nil {
		return mapMessagingError(err)
	}
	if _, err := store.ensureReplyThreadScopeProjection(ctx, tx, replyThread.ThreadChannelID); err != nil {
		return err
	}
	if err := message.ActivateThreadSubscription(ctx, tx, prepared.membership.Member.ID, replyThread.ThreadChannelID, delivery.Message.Timestamp); err != nil {
		return err
	}
	if len(prepared.startAgentThreadRecipients) > 0 {
		if err := retargetStartAgentThreadDelivery(ctx, tx, delivery, prepared.target, replyThread, prepared.startAgentThreadRecipients); err != nil {
			return err
		}
	} else if err := activateStartedReplyThreadRecipients(ctx, tx, replyThread.ThreadChannelID, delivery.WakeRecipients, delivery.Message.Timestamp); err != nil {
		return err
	}
	updatedReplyThread, err := loadStartedReplyThreadReplay(ctx, tx, prepared, strings.TrimSpace(workspaceID), delivery.MessageID)
	if err != nil {
		return err
	}
	delivery.StartedReplyThread = &updatedReplyThread
	delivery.ReplyThreadUpdated = &message.ReplyThreadUpdated{
		ParentMessageID: delivery.MessageID, ThreadChannelID: strings.TrimSpace(updatedReplyThread.ThreadChannelID),
		ReplyCount: updatedReplyThread.ReplyCount, LastReplyAt: updatedReplyThread.LastReplyAt,
		ParticipantIDs: updatedReplyThread.ParticipantIDs, Participants: updatedReplyThread.Participants,
		ReplyAuthorIDs: updatedReplyThread.ReplyAuthorIDs, ReplyAuthors: updatedReplyThread.ReplyAuthors,
	}
	return nil
}
