package taskboard

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/testfixture"

	"github.com/stretchr/testify/require"
)

func TestTaskCollaboratorsStaySparseAndResolveEffectiveUnion(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Coordinate task collaborator policy"},
		Metadata: &CreateTaskMetadata{
			SourceThreadChannelID:       threadChannelID,
			SourceThreadParentChannelID: fixture.channelID,
			SourceThreadParentMessageID: "msg_task_collaborator_parent",
			SourceThreadRef:             "#all:task-collaborators",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.ownerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.ErrorIs(t, err, ErrForbidden)

	require.Equal(t, int64(0), countTaskScopeExecutionRows(t, ctx, fixture))
	require.Equal(t, int64(0), countTaskScopePeopleRows(t, ctx, fixture))

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     "observer",
		},
	})
	require.Error(t, err)
	require.ErrorContains(t, err, "invalid collaborator role")
	require.Equal(t, int64(0), countTaskScopeExecutionRows(t, ctx, fixture))
	require.Equal(t, int64(0), countTaskScopePeopleRows(t, ctx, fixture))

	effective, err := fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, fixture.ownerMemberID, effective.ExecutionOwnerMemberID)
	require.NotNil(t, effective.OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, effective.OwnerProfile.MemberID)
	require.Equal(t, fixture.ownerAvatarURL, effective.OwnerProfile.AvatarURL)
	require.Equal(t, fixture.ownerUserID, effective.Owner.ID)
	require.Empty(t, effective.Collaborators)

	taskCollaborator, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)
	require.Equal(t, "task", taskCollaborator.Collaborator.ScopeType)
	require.True(t, taskCollaborator.Collaborator.Active)
	require.NotNil(t, taskCollaborator.Collaborator.MemberProfile)
	require.Equal(t, fixture.reviewerMemberID, taskCollaborator.Collaborator.MemberProfile.MemberID)
	require.Equal(t, fixture.reviewerAvatarURL, taskCollaborator.Collaborator.MemberProfile.AvatarURL)
	require.NotNil(t, taskCollaborator.Collaborator.AddedByProfile)
	require.Equal(t, fixture.ownerMemberID, taskCollaborator.Collaborator.AddedByProfile.MemberID)
	require.Equal(t, fixture.ownerAvatarURL, taskCollaborator.Collaborator.AddedByProfile.AvatarURL)
	require.Equal(t, fixture.reviewerUserID, taskCollaborator.Collaborator.Member.ID)
	require.Equal(t, int64(1), countTaskScopeExecutionRows(t, ctx, fixture))
	require.Equal(t, int64(1), countTaskScopePeopleRows(t, ctx, fixture))
	require.Equal(t, int64(0), countTaskScopeOwnerPeopleRows(t, ctx, fixture))

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "thread",
			ScopeID:   threadChannelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	effective, err = fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, effective.Collaborators, 1)
	require.Equal(t, effective.People, effective.Collaborators, "collaborators is a deprecated People alias")
	require.Equal(t, fixture.reviewerMemberID, effective.Collaborators[0].MemberID)
	require.NotNil(t, effective.Collaborators[0].MemberProfile)
	require.Equal(t, fixture.reviewerMemberID, effective.Collaborators[0].MemberProfile.MemberID)
	require.Equal(t, fixture.reviewerAvatarURL, effective.Collaborators[0].MemberProfile.AvatarURL)
	require.Equal(t, taskCollaboratorRoleContributor, effective.Collaborators[0].Role)
	require.Len(t, effective.Collaborators[0].Sources, 3)
	require.Equal(t, []string{"task", "thread", "channel"}, []string{
		effective.Collaborators[0].Sources[0].ScopeType,
		effective.Collaborators[0].Sources[1].ScopeType,
		effective.Collaborators[0].Sources[2].ScopeType,
	})

	_, err = fixture.service.RemoveTaskCollaborator(ctx, RemoveTaskCollaboratorRequest{
		Context:  taskboardUserContext(fixture),
		Task:     TaskRef{TaskID: taskID},
		MemberID: fixture.reviewerMemberID,
	})
	require.NoError(t, err)

	effective, err = fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, effective.Collaborators, 1)
	require.Len(t, effective.Collaborators[0].Sources, 2)
	require.Equal(t, []string{"thread", "channel"}, []string{
		effective.Collaborators[0].Sources[0].ScopeType,
		effective.Collaborators[0].Sources[1].ScopeType,
	})
}

func TestTaskCollaboratorsAllowInstalledAppsAsPeopleCollaborators(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	appMemberID := seedTaskCollaboratorAppInstallation(t, ctx, fixture, "collaborator")
	appAvatarURL := taskCollaboratorAppAvatarURL("collaborator")
	appPrincipalID := taskCollaboratorAppPrincipalID("collaborator")

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"App collaborator can provide private-agent context"},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	var appMemberships int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_memberships
WHERE scope_id = $1 AND member_id = $2
`, fixture.channelID, appMemberID).Scan(&appMemberships))
	require.Equal(t, int64(0), appMemberships, "app collaborators must not require ordinary channel membership")

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: appMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)
	require.Equal(t, appMemberID, added.Collaborator.MemberID)
	require.NotNil(t, added.Collaborator.MemberProfile)
	require.Equal(t, appMemberID, added.Collaborator.MemberProfile.MemberID)
	require.Equal(t, appPrincipalID, added.Collaborator.MemberProfile.PrincipalID)
	require.Equal(t, appAvatarURL, added.Collaborator.MemberProfile.AvatarURL)
	require.Equal(t, "github", added.Collaborator.MemberProfile.ServiceSlug)
	require.Equal(t, "app", added.Collaborator.Member.Type)
	require.Equal(t, appPrincipalID, added.Collaborator.Member.ID)
	require.Equal(t, "GitHub", added.Collaborator.Member.Name)
	require.True(t, added.Collaborator.Active)

	local, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, local.Collaborators, 1)
	require.NotNil(t, local.Collaborators[0].MemberProfile)
	require.Equal(t, appMemberID, local.Collaborators[0].MemberProfile.MemberID)
	require.Equal(t, appAvatarURL, local.Collaborators[0].MemberProfile.AvatarURL)
	require.Equal(t, "app", local.Collaborators[0].Member.Type)

	effective, err := fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, effective.Collaborators, 1)
	require.Equal(t, appMemberID, effective.Collaborators[0].MemberID)
	require.NotNil(t, effective.Collaborators[0].MemberProfile)
	require.Equal(t, appMemberID, effective.Collaborators[0].MemberProfile.MemberID)
	require.Equal(t, appAvatarURL, effective.Collaborators[0].MemberProfile.AvatarURL)
	require.Equal(t, "app", effective.Collaborators[0].Member.Type)
	require.Len(t, effective.Collaborators[0].Sources, 1)
	require.Equal(t, added.Collaborator.ID, effective.Collaborators[0].Sources[0].RelationID)

	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE service_installations
SET status = 'disabled',
    disabled_at = $2,
    updated_at = $2
WHERE id IN (
    SELECT service_installation_id
    FROM service_members
    WHERE member_id = $1
)
`, appMemberID, time.Now().UTC())
	activeAfterDisable, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Empty(t, activeAfterDisable.Collaborators)
	inactiveAfterDisable, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		Task:            TaskRef{TaskID: taskID},
		IncludeInactive: true,
	})
	require.NoError(t, err)
	require.Len(t, inactiveAfterDisable.Collaborators, 1)
	require.False(t, inactiveAfterDisable.Collaborators[0].Active)
	effectiveAfterDisable, err := fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Empty(t, effectiveAfterDisable.Collaborators)

	removed, err := fixture.service.RemoveTaskCollaborator(ctx, RemoveTaskCollaboratorRequest{
		Context:  taskboardUserContext(fixture),
		Task:     TaskRef{TaskID: taskID},
		MemberID: appMemberID,
	})
	require.NoError(t, err)
	require.False(t, removed.Collaborator.Active)
	require.Equal(t, "app", removed.Collaborator.Member.Type)
}

func TestTaskCollaboratorsDoNotTreatPublicAppVisibilityAsManagementAuthority(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: NewUserServiceContext(
			fixture.workspaceID,
			fixture.reviewerUserID,
			fixture.reviewerDisplayName,
		),
		Board:  BoardRef{ChannelID: fixture.channelID},
		Titles: []string{"Public App collaborator authority"},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID
	reviewerContext := NewUserServiceContext(
		fixture.workspaceID,
		fixture.reviewerUserID,
		fixture.reviewerDisplayName,
	)

	// Exact product Apps preserve the existing collaborator contract for an
	// ordinary execution owner.
	productMemberID := seedTaskCollaboratorAppInstallation(t, ctx, fixture, "ordinary-product")
	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: reviewerContext,
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: productMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	appMemberID, appAppID := seedTaskCollaboratorExternalApp(
		t, ctx, fixture, "public-authority", "public",
	)
	addApp := func(serviceContext ServiceContext) (AddTaskCollaboratorResponse, error) {
		return fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
			Context: serviceContext,
			Task:    TaskRef{TaskID: taskID},
			Collaborator: TaskCollaboratorInput{
				MemberID: appMemberID,
				Role:     taskCollaboratorRoleContributor,
			},
		})
	}

	_, err = addApp(reviewerContext)
	require.ErrorIs(t, err, ErrNotFound, "public catalog visibility must not authorize task collaborators")
	var appGrantCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_participant_metadata
WHERE member_id = $1
  AND active_key IS NOT NULL
`, appMemberID).Scan(&appGrantCount))
	require.Zero(t, appGrantCount)

	now := time.Now().UTC()
	managerID := "botmanager_task_collaborator_public_authority"
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO extension_definition_managers (
  id, workspace_id, service_definition_id, member_id, status, version,
  granted_by_member_id, created_at, updated_at, ended_at
) VALUES ($1, $2, $3, $4, 'active', 1, $5, $6, $6, NULL)
`, managerID, fixture.workspaceID, appAppID, fixture.reviewerMemberID, fixture.ownerMemberID, now)
	managed, err := addApp(reviewerContext)
	require.NoError(t, err)
	require.Equal(t, appMemberID, managed.Collaborator.MemberID)

	// Catalog audience remains irrelevant in both directions. Ending the manager
	// assignment revokes this mutation even after the App becomes private.
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE extension_catalog_attributions
SET catalog_visibility = 'private', updated_at = $3
WHERE workspace_id = $1 AND service_definition_id = $2
`, fixture.workspaceID, appAppID, now.Add(time.Second))
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE extension_definition_managers
SET status = 'ended', ended_at = $2, updated_at = $2, version = version + 1
WHERE id = $1
`, managerID, now.Add(2*time.Second))
	_, err = addApp(reviewerContext)
	require.ErrorIs(t, err, ErrNotFound)

	// A current workspace owner/admin retains App management authority without a
	// manager assignment and may update the existing collaborator relation.
	adminUpdated, err := addApp(taskboardUserContext(fixture))
	require.NoError(t, err)
	require.Equal(t, managed.Collaborator.ID, adminUpdated.Collaborator.ID)
}

func TestListAgentCollaboratorsIsOwnerScopedAndFiltersActiveSources(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)
	appMemberID := seedTaskCollaboratorAppInstallation(t, ctx, fixture, "aggregate")

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Aggregate owner collaborators"},
		Metadata: &CreateTaskMetadata{
			SourceThreadChannelID:       threadChannelID,
			SourceThreadParentChannelID: fixture.channelID,
			SourceThreadParentMessageID: "msg_task_collaborator_parent",
			SourceThreadRef:             "#all:task-collaborators",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	channelGrant, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)
	appGrant, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID:  appMemberID,
			Role:      taskCollaboratorRoleContributor,
			ExpiresAt: timePointer(time.Now().UTC().Add(24 * time.Hour)),
		},
	})
	require.NoError(t, err)

	countingDB := &taskboardQueryCountingDB{DB: fixture.pool}
	countingService := New(countingDB)
	ownerList, err := countingService.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Limit:   50,
	})
	require.NoError(t, err)
	require.Len(t, countingDB.QueriesContaining("requested_sources AS"), 1, "agent collaborator source labels must be loaded in one bounded batch")
	require.Equal(t, fixture.ownerMemberID, ownerList.ExecutionOwnerMemberID)
	require.NotNil(t, ownerList.OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, ownerList.OwnerProfile.MemberID)
	require.Len(t, ownerList.Collaborators, 2)
	require.Equal(t, ownerList.People, ownerList.Collaborators, "collaborators is a deprecated People alias")

	byMember := make(map[string]AgentCollaboratorSummary, len(ownerList.Collaborators))
	for _, collaborator := range ownerList.Collaborators {
		byMember[collaborator.MemberID] = collaborator
	}
	require.Equal(t, 1, byMember[fixture.reviewerMemberID].ScopeCount)
	require.NotNil(t, byMember[fixture.reviewerMemberID].MemberProfile)
	require.Equal(t, fixture.reviewerMemberID, byMember[fixture.reviewerMemberID].MemberProfile.MemberID)
	require.Equal(t, channelGrant.Collaborator.ID, byMember[fixture.reviewerMemberID].Sources[0].RelationID)
	require.Equal(t, "channel", byMember[fixture.reviewerMemberID].Sources[0].ScopeType)
	require.Equal(t, "all", byMember[fixture.reviewerMemberID].Sources[0].ScopeDisplayName)
	require.Equal(t, fixture.channelID, byMember[fixture.reviewerMemberID].Sources[0].ChannelID)
	require.Equal(t, "all", byMember[fixture.reviewerMemberID].Sources[0].ChannelDisplayName)
	require.Equal(t, 1, byMember[appMemberID].ScopeCount)
	require.NotNil(t, byMember[appMemberID].MemberProfile)
	require.Equal(t, appMemberID, byMember[appMemberID].MemberProfile.MemberID)
	require.Equal(t, appGrant.Collaborator.ID, byMember[appMemberID].Sources[0].RelationID)
	require.Equal(t, "task", byMember[appMemberID].Sources[0].ScopeType)
	require.Equal(t, taskID, byMember[appMemberID].Sources[0].ScopeID)
	require.Equal(t, "#t1 Aggregate owner collaborators", byMember[appMemberID].Sources[0].ScopeDisplayName)
	require.Equal(t, fixture.channelID, byMember[appMemberID].Sources[0].ChannelID)
	require.Equal(t, "all", byMember[appMemberID].Sources[0].ChannelDisplayName)
	require.Equal(t, 1, byMember[appMemberID].SoonExpiringCount)

	filtered, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		SourceScopeType: "channel",
		SourceScopeID:   fixture.channelID,
		Limit:           50,
	})
	require.NoError(t, err)
	require.Equal(t, "all", filtered.SourceScopeDisplayName)
	require.Equal(t, "all", filtered.SourceChannelDisplayName)
	require.Len(t, filtered.Collaborators, 1)
	require.Equal(t, fixture.reviewerMemberID, filtered.Collaborators[0].MemberID)

	removed, err := fixture.service.RemoveTaskCollaborator(ctx, RemoveTaskCollaboratorRequest{
		Context:  taskboardUserContext(fixture),
		Task:     TaskRef{TaskID: taskID},
		MemberID: appMemberID,
	})
	require.NoError(t, err)
	require.False(t, removed.Collaborator.Active)

	activeAfterRemove, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Limit:   50,
	})
	require.NoError(t, err)
	require.Len(t, activeAfterRemove.Collaborators, 1)
	require.Equal(t, fixture.reviewerMemberID, activeAfterRemove.Collaborators[0].MemberID)

	inactive, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		IncludeInactive: true,
		Limit:           50,
	})
	require.NoError(t, err)
	require.Len(t, inactive.Collaborators, 2)
	require.False(t, collaboratorSummaryByMember(inactive.Collaborators, appMemberID).Sources[0].Active)

	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE members
SET workspace_role = 'admin',
    updated_at = $3
WHERE workspace_id = $1 AND id = $2
`, fixture.workspaceID, fixture.reviewerMemberID, time.Now().UTC())
	adminView, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Limit:   50,
	})
	require.NoError(t, err)
	require.Equal(t, fixture.reviewerMemberID, adminView.ExecutionOwnerMemberID)
	require.Empty(t, adminView.Collaborators, "workspace admins must not enumerate another owner's private-agent collaborators")
}

func TestListAgentCollaboratorsRejectsUnreadableSourceFilter(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	now := time.Now().UTC()
	secretChannelID := "chn_task_collaborator_secret_filter"
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
  latest_message_at, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'channel', $3, 0, 0, NULL, 'active', $4, $5, $5)
`, secretChannelID, fixture.workspaceID, "channel:task-collaborator-secret-filter", fixture.reviewerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_channel_profiles (
  scope_id, workspace_id, slug, display_name, description, visibility,
  owner_member_id, is_all_channel, created_at, updated_at
) VALUES ($1, $2, 'secret-filter', 'Secret Filter Channel', '', 'private', $3, FALSE, $4, $4)
`, secretChannelID, fixture.workspaceID, fixture.reviewerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $3, $3)
`, secretChannelID, fixture.reviewerMemberID, now)

	_, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		SourceScopeType: "channel",
		SourceScopeID:   secretChannelID,
		Limit:           50,
	})
	require.ErrorIs(t, err, ErrForbidden)
}

func TestAgentCollaboratorSourceDisplayDoesNotLeakCrossWorkspaceTask(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	now := time.Now().UTC()
	foreignWorkspaceID := "wsp_task_collaborator_foreign_display"
	foreignChannelID := "chn_task_collaborator_foreign_display"
	foreignBoardID := "tb_task_collaborator_foreign_display"
	foreignTaskID := "tsk_task_collaborator_foreign_display"
	foreignTaskScopeID := "scp_task_collaborator_foreign_display"
	foreignOrgID := testfixture.SeedWorkspaceOrganization(t, ctx, fixture.pool, foreignWorkspaceID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, 'task-collaborator-foreign-display', 'Task Collaborator Foreign Display', 0, $3, $3)
`, foreignWorkspaceID, foreignOrgID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
  latest_message_at, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'channel', $3, 0, 0, NULL, 'active', $4, $5, $5)
`, foreignChannelID, foreignWorkspaceID, "channel:task-collaborator-foreign-display", fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO task_boards (
  id, channel_id, workspace_id, next_task_number, created_at, updated_at
) VALUES ($1, $2, $3, 100, $4, $4)
`, foreignBoardID, foreignChannelID, foreignWorkspaceID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
  latest_message_at, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'task', $3, 0, 0, NULL, 'active', $4, $5, $5)
`, foreignTaskScopeID, foreignWorkspaceID, "task:"+foreignTaskID, fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO tasks (
  id, task_scope_id, board_id, channel_id, task_number, title, status,
  created_by_member_id, created_by_ref, created_at, updated_at
) VALUES ($1, $2, $3, $4, 99, 'Foreign Secret Task', 'todo', $5, $5, $6, $6)
`, foreignTaskID, foreignTaskScopeID, foreignBoardID, foreignChannelID, fixture.ownerMemberID, now)

	displays, err := loadAgentCollaboratorSourceDisplays(ctx, fixture.pool, fixture.workspaceID, nil, "task", foreignTaskID)
	require.NoError(t, err)
	require.Empty(t, displays[newAgentCollaboratorSourceKey("task", foreignTaskID)].ScopeDisplayName)
}

func TestListAgentCollaboratorsUsesThreadParentMessageAsReadableSourceLabel(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)
	insertTaskCollaboratorSourceMessage(t, ctx, fixture, "msg_task_collaborator_parent", fixture.ownerMemberID, 1, 1, "排查支付回调失败", time.Now().UTC())

	_, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "thread",
			ScopeID:   threadChannelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	response, err := fixture.service.ListAgentCollaborators(ctx, ListAgentCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		SourceScopeType: "thread",
		SourceScopeID:   threadChannelID,
		Limit:           50,
	})
	require.NoError(t, err)
	require.Equal(t, "排查支付回调失败", response.SourceScopeDisplayName)
	require.Equal(t, "all", response.SourceChannelDisplayName)
	require.Len(t, response.Collaborators, 1)
	require.Len(t, response.Collaborators[0].Sources, 1)
	require.Equal(t, "thread", response.Collaborators[0].Sources[0].ScopeType)
	require.Equal(t, "排查支付回调失败", response.Collaborators[0].Sources[0].ScopeDisplayName)
	require.Equal(t, fixture.channelID, response.Collaborators[0].Sources[0].ChannelID)
	require.Equal(t, "all", response.Collaborators[0].Sources[0].ChannelDisplayName)
}

func TestListEffectiveTaskCollaboratorsBatchesReadableScopeChecks(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Batch readable People rows"},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	memberIDs := make([]string, 0, 25)
	var singleListQueries int
	for index := 0; index < 25; index++ {
		memberID := seedTaskCollaboratorBatchMember(t, ctx, fixture, index)
		memberIDs = append(memberIDs, memberID)
		_, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
			Context: taskboardUserContext(fixture),
			Task:    TaskRef{TaskID: taskID},
			Collaborator: TaskCollaboratorInput{
				MemberID: memberID,
				Role:     taskCollaboratorRoleContributor,
			},
		})
		require.NoError(t, err)
		if index == 0 {
			countingDB := &taskboardQueryCountingDB{DB: fixture.pool}
			countingService := New(countingDB)
			single, err := countingService.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
				Context: taskboardUserContext(fixture),
				Task:    TaskRef{TaskID: taskID},
			})
			require.NoError(t, err)
			require.Len(t, single.Collaborators, 1)
			singleListQueries = countingDB.Queries()
			require.Positive(t, singleListQueries)
		}
	}

	countingDB := &taskboardQueryCountingDB{DB: fixture.pool}
	countingService := New(countingDB)
	effective, err := countingService.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, effective.Collaborators, len(memberIDs))
	require.LessOrEqual(t, countingDB.Queries(), singleListQueries+2, "list effective collaborators must batch readable scope checks instead of querying per collaborator row")
}

func TestThreadPeopleCollaboratorCanWriteDiscussionOnSourceThreadTask(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Thread people collaborator writes task discussion"},
		Metadata: &CreateTaskMetadata{
			SourceThreadChannelID:       threadChannelID,
			SourceThreadParentChannelID: fixture.channelID,
			SourceThreadParentMessageID: "msg_task_collaborator_parent",
			SourceThreadRef:             "#all:task-collaborators",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "thread",
			ScopeID:   threadChannelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	discussion, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "Thread People collaborator can contribute to the task created from this thread.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, fixture.reviewerUserID, discussion.Message.CreatedBy.ID)

	require.NotEmpty(t, added.Collaborator.ID)
}

func TestListEffectiveTaskCollaboratorsKeepsDMParentThreadScopeReadable(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	memberID := seedTaskCollaboratorStandaloneMember(t, ctx, fixture, "dm_thread")
	threadChannelID, dmThreadID := seedTaskCollaboratorDMReplyThread(t, ctx, fixture, memberID)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"DM parent thread collaborator"},
		Metadata: &CreateTaskMetadata{
			SourceThreadChannelID:       threadChannelID,
			SourceThreadParentChannelID: dmThreadID,
			SourceThreadParentMessageID: "msg_dm_task_collaborator_parent",
			SourceThreadRef:             "dm:task-collaborators",
		},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "thread",
			ScopeID:   threadChannelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: memberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	effective, err := fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Len(t, effective.Collaborators, 1)
	require.Equal(t, memberID, effective.Collaborators[0].MemberID)
	require.Len(t, effective.Collaborators[0].Sources, 1)
	require.Equal(t, "thread", effective.Collaborators[0].Sources[0].ScopeType)
	require.Equal(t, threadChannelID, effective.Collaborators[0].Sources[0].ScopeID)
	require.Equal(t, added.Collaborator.ID, effective.Collaborators[0].Sources[0].RelationID)
}

func TestTaskCollaboratorLeaveExpireAndDiscussionAudit(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Audit collaborator discussion ingress"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	_, err = fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "I can see the task but must not enter owner private-agent context yet.",
		},
	})
	require.ErrorIs(t, err, ErrForbidden)
	require.Equal(t, int64(0), countTaskDiscussionEntries(t, ctx, fixture, taskID))

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID:  fixture.reviewerMemberID,
			Role:      taskCollaboratorRoleContributor,
			ExpiresAt: timePointer(time.Now().UTC().Add(time.Hour)),
		},
	})
	require.NoError(t, err)

	discussion, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "I can contribute from the task discussion.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, fixture.reviewerUserID, discussion.Message.CreatedBy.ID)

	require.NotEmpty(t, added.Collaborator.ID)

	left, err := fixture.service.LeaveTaskCollaborator(ctx, LeaveTaskCollaboratorRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.False(t, left.Collaborator.Active)
	require.NotNil(t, left.Collaborator.LeftAt)
	effective, err := fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Empty(t, effective.Collaborators)

	expiring, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID:  fixture.reviewerMemberID,
			Role:      taskCollaboratorRoleContributor,
			ExpiresAt: timePointer(time.Now().UTC().Add(time.Hour)),
		},
	})
	require.NoError(t, err)
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE task_scope_participant_metadata
SET expires_at = $2,
    updated_at = $2
WHERE id = $1
`, expiring.Collaborator.ID, time.Now().UTC().Add(-time.Minute))

	effective, err = fixture.service.ListEffectiveTaskCollaborators(ctx, ListEffectiveTaskCollaboratorsRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Empty(t, effective.Collaborators)
	require.Equal(t, int64(0), countTaskScopePeopleRows(t, ctx, fixture), "expired People metadata must remove the active People collaborator metadata")
	inactive, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context:         taskboardUserContext(fixture),
		Task:            TaskRef{TaskID: taskID},
		IncludeInactive: true,
	})
	require.NoError(t, err)
	require.Len(t, inactive.Collaborators, 2)
}

func TestTaskCollaboratorCanExplicitlyWakeOwnerPrivateAgent(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $3,
    updated_at = $4
WHERE scope_id = $2
  AND member_id = $5
`, fixture.workspaceID, fixture.channelID, fixture.ownerMemberID, time.Now().UTC(), fixture.agentMemberID)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Collaborator explicit wake"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	_, err = fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "@task-detail-agent non-collaborators cannot write task discussions.",
		},
	})
	require.ErrorIs(t, err, ErrForbidden)

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	discussion, err := fixture.service.SendTaskScopeMessage(ctx, SendTaskScopeMessageRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
		Message: TaskScopeMessageInput{
			Content: "@task-detail-agent please follow this collaborator comment.",
		},
	})
	require.NoError(t, err)
	require.Equal(t, StatusTodo, Status(discussion.Task.Status))
	require.Nil(t, discussion.Task.AssigneeProfile)
}

func TestTaskCollaboratorAddQueuesSparseNotificationWithoutMarkingDelivered(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Notify collaborator add"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)
	require.Nil(t, added.Collaborator.NotifiedAt, "notifiedAt must not claim delivery just because the collaborator row was inserted")
	require.Equal(t, "pending", added.Collaborator.NotificationState)
	require.NotNil(t, added.Collaborator.NotificationQueuedAt)

	var eventCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_participant_metadata
WHERE id = $1
  AND member_id = $2
  AND added_by_member_id = $3
  AND add_notification_state = 'queued'
  AND add_notification_queued_at IS NOT NULL
  AND notified_at IS NULL
`, added.Collaborator.ID, fixture.reviewerMemberID, fixture.ownerMemberID).Scan(&eventCount))
	require.Equal(t, int64(1), eventCount)
}

func TestResolvePrivateAgentContextStateClassifiesVisibleMessages(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	observerMemberID, _ := seedTaskCollaboratorObserver(t, ctx, fixture)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Resolve private-agent context state"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	now := time.Date(2026, 5, 5, 10, 30, 0, 0, time.UTC)
	insertTaskCollaboratorSourceMessage(t, ctx, fixture, "msg_ctx_owner", fixture.ownerMemberID, 1, 1, "owner source", now)
	insertTaskCollaboratorSourceMessage(t, ctx, fixture, "msg_ctx_people", fixture.reviewerMemberID, 2, 2, "people source", now.Add(time.Minute))
	insertTaskCollaboratorSourceMessage(t, ctx, fixture, "msg_ctx_needs_share", observerMemberID, 3, 3, "needs share", now.Add(2*time.Minute))
	insertTaskCollaboratorSourceMessage(t, ctx, fixture, "msg_ctx_owner_quote_needed", observerMemberID, 4, 4, "owner quote needed", now.Add(3*time.Minute))
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $3,
    updated_at = $4
WHERE scope_id = $2
  AND member_id = $5
`, fixture.workspaceID, fixture.channelID, fixture.ownerMemberID, now, fixture.agentMemberID)
	insertTaskCollaboratorSourceMessageWithAuthorType(t, ctx, fixture, "msg_ctx_owner_private_agent", fixture.agentMemberID, "agent", 5, 5, "owner private agent source", now.Add(4*time.Minute))

	addedPerson, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	resolved, err := fixture.service.ResolvePrivateAgentContextState(ctx, ResolvePrivateAgentContextStateRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		MessageIDs: []string{
			"msg_ctx_owner",
			"msg_ctx_people",
			"msg_ctx_needs_share",
			"msg_ctx_owner_quote_needed",
			"msg_ctx_owner_private_agent",
		},
	})
	require.NoError(t, err)
	require.Len(t, resolved.States, 5)

	byMessage := privateAgentContextStatesByMessage(resolved.States)
	require.Equal(t, privateAgentContextStateEligible, byMessage["msg_ctx_owner"].State)
	require.Equal(t, "owner", byMessage["msg_ctx_owner"].Relation.Kind)
	require.True(t, byMessage["msg_ctx_owner"].CanWake)

	require.Equal(t, privateAgentContextStateEligible, byMessage["msg_ctx_people"].State)
	require.Equal(t, "people", byMessage["msg_ctx_people"].Relation.Kind)
	require.Equal(t, addedPerson.Collaborator.ID, byMessage["msg_ctx_people"].Relation.RelationID)

	require.Equal(t, privateAgentContextStateShareNeeded, byMessage["msg_ctx_needs_share"].State)
	require.True(t, byMessage["msg_ctx_needs_share"].CanShare)
	require.False(t, byMessage["msg_ctx_needs_share"].CanWake)

	require.Equal(t, privateAgentContextStateShareNeeded, byMessage["msg_ctx_owner_quote_needed"].State)
	require.True(t, byMessage["msg_ctx_owner_quote_needed"].CanShare)
	require.False(t, byMessage["msg_ctx_owner_quote_needed"].CanWake)

	require.Equal(t, privateAgentContextStateEligible, byMessage["msg_ctx_owner_private_agent"].State)
	require.Equal(t, "owner", byMessage["msg_ctx_owner_private_agent"].Relation.Kind)

	afterNeedsShare := int64(2)
	beforeOwnerQuoteNeeded := int64(4)
	windowResolved, err := fixture.service.ResolvePrivateAgentContextState(ctx, ResolvePrivateAgentContextStateRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Window: &PrivateAgentContextStateWindowInput{
			AfterSeq:  &afterNeedsShare,
			BeforeSeq: &beforeOwnerQuoteNeeded,
			Limit:     10,
		},
	})
	require.NoError(t, err)
	require.Len(t, windowResolved.States, 1)
	require.Equal(t, "msg_ctx_needs_share", windowResolved.States[0].MessageID)
	require.Equal(t, privateAgentContextStateShareNeeded, windowResolved.States[0].State)
	require.Nil(t, windowResolved.States[0].Relation)
}

func TestResolvePrivateAgentContextStateKeepsQueryCountBoundedByBatch(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Bound private-agent context state queries"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	now := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $3,
    updated_at = $4
WHERE scope_id = $2
  AND member_id = $5
`, fixture.workspaceID, fixture.channelID, fixture.ownerMemberID, now, fixture.agentMemberID)

	messageIDs := make([]string, 25)
	for index := range messageIDs {
		messageID := fmt.Sprintf("msg_ctx_bounded_%02d", index)
		messageIDs[index] = messageID
		switch index % 3 {
		case 0:
			insertTaskCollaboratorSourceMessage(t, ctx, fixture, messageID, fixture.ownerMemberID, int64(index+1), int64(index+1), "owner source", now.Add(time.Duration(index)*time.Second))
		case 1:
			insertTaskCollaboratorSourceMessage(t, ctx, fixture, messageID, fixture.reviewerMemberID, int64(index+1), int64(index+1), "people source", now.Add(time.Duration(index)*time.Second))
		default:
			insertTaskCollaboratorSourceMessageWithAuthorType(t, ctx, fixture, messageID, fixture.agentMemberID, "agent", int64(index+1), int64(index+1), "private agent source", now.Add(time.Duration(index)*time.Second))
		}
	}

	countingDB := &taskboardQueryCountingDB{DB: fixture.pool}
	countingService := New(countingDB)
	_, err = countingService.ResolvePrivateAgentContextState(ctx, ResolvePrivateAgentContextStateRequest{
		Context:    taskboardUserContext(fixture),
		Task:       TaskRef{TaskID: taskID},
		MessageIDs: messageIDs[:1],
	})
	require.NoError(t, err)
	singleMessageQueries := countingDB.Queries()

	countingDB.Reset()
	resolved, err := countingService.ResolvePrivateAgentContextState(ctx, ResolvePrivateAgentContextStateRequest{
		Context:    taskboardUserContext(fixture),
		Task:       TaskRef{TaskID: taskID},
		MessageIDs: messageIDs,
	})
	require.NoError(t, err)
	require.Len(t, resolved.States, len(messageIDs))
	require.LessOrEqual(t, countingDB.Queries(), singleMessageQueries+2, "private-agent context state should batch per-message facts")
}

func TestResolvePrivateAgentContextContributionCapabilityUsesBoundedEffectiveChain(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	_, observerUserID := seedTaskCollaboratorObserver(t, ctx, fixture)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Resolve contribution capability"},
		Metadata: &CreateTaskMetadata{
			SourceThreadChannelID:       threadChannelID,
			SourceThreadParentChannelID: fixture.channelID,
			SourceThreadParentMessageID: "msg_contribution_capability_parent",
			SourceThreadRef:             "#all:contribution-capability",
		},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID
	require.Equal(t, int64(0), countTaskScopeExecutionRows(t, ctx, fixture))

	ownerCapability, err := fixture.service.ResolvePrivateAgentContextContributionCapability(ctx, ResolvePrivateAgentContextContributionCapabilityRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, "owner", ownerCapability.Relation)
	require.True(t, ownerCapability.CanContributeContext)
	require.True(t, ownerCapability.CanWake)
	require.True(t, ownerCapability.CanShare)
	require.Equal(t, int64(0), countTaskScopeExecutionRows(t, ctx, fixture), "owner-only contribution capability must not create an empty task execution row")

	observerCapability, err := fixture.service.ResolvePrivateAgentContextContributionCapability(ctx, ResolvePrivateAgentContextContributionCapabilityRequest{
		Context: NewUserServiceContext(fixture.workspaceID, observerUserID, "Task Collaborator Observer"),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, "none", observerCapability.Relation)
	require.False(t, observerCapability.CanContributeContext)
	require.False(t, observerCapability.CanWake)
	require.False(t, observerCapability.CanShare)

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	reviewerCapability, err := fixture.service.ResolvePrivateAgentContextContributionCapability(ctx, ResolvePrivateAgentContextContributionCapabilityRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Equal(t, "collaborator", reviewerCapability.Relation)
	require.True(t, reviewerCapability.CanContributeContext)
	require.True(t, reviewerCapability.CanWake)
	require.False(t, reviewerCapability.CanShare)
	require.NotNil(t, reviewerCapability.RelationSource)
	require.Equal(t, "channel", reviewerCapability.RelationSource.ScopeType)
	require.Equal(t, fixture.channelID, reviewerCapability.RelationSource.ScopeID)
}

func TestListPrivateAgentMentionCandidatesUsesLazyOwnerAndPeopleScope(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	markTaskboardFixtureAgentPrivate(t, ctx, fixture)
	reviewerPrivateAgentID := seedTaskboardPrivateAgent(t, ctx, fixture, "agt_task_detail_reviewer_private", "mem_task_detail_reviewer_private_agent", "reviewer-private-agent", "Reviewer Private Agent", fixture.reviewerMemberID)

	ownerCandidates, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "task-detail",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, ownerCandidates.Candidates, 1)
	require.Equal(t, fixture.agentID, ownerCandidates.Candidates[0].ID)
	require.NotNil(t, ownerCandidates.Candidates[0].OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, ownerCandidates.Candidates[0].OwnerProfile.MemberID)
	require.Equal(t, fixture.ownerUserID, ownerCandidates.Candidates[0].OwnerProfile.PrincipalID)
	require.Equal(t, fixture.ownerDisplayName, ownerCandidates.Candidates[0].OwnerProfile.DisplayName)
	require.Equal(t, fixture.ownerMemberID, ownerCandidates.Candidates[0].OwnerMemberID)
	require.Equal(t, fixture.ownerDisplayName, ownerCandidates.Candidates[0].OwnerDisplayName)
	require.Equal(t, "owner", ownerCandidates.Candidates[0].Relation)

	ownerTokenCandidates, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.ownerUserID, fixture.ownerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "detail",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, ownerTokenCandidates.Candidates, 1)
	require.Equal(t, fixture.agentID, ownerTokenCandidates.Candidates[0].ID)

	reviewerBeforeGrant, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "task-detail",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Empty(t, reviewerBeforeGrant.Candidates)

	reviewerOwnCandidates, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "reviewer-private",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, reviewerOwnCandidates.Candidates, 1)
	require.Equal(t, reviewerPrivateAgentID, reviewerOwnCandidates.Candidates[0].ID)
	require.NotNil(t, reviewerOwnCandidates.Candidates[0].OwnerProfile)
	require.Equal(t, fixture.reviewerMemberID, reviewerOwnCandidates.Candidates[0].OwnerProfile.MemberID)
	require.Equal(t, fixture.reviewerUserID, reviewerOwnCandidates.Candidates[0].OwnerProfile.PrincipalID)
	require.Equal(t, fixture.reviewerMemberID, reviewerOwnCandidates.Candidates[0].OwnerMemberID)
	require.Equal(t, "owner", reviewerOwnCandidates.Candidates[0].Relation)

	reviewerOwnTokenCandidates, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "private",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, reviewerOwnTokenCandidates.Candidates, 1)
	require.Equal(t, reviewerPrivateAgentID, reviewerOwnTokenCandidates.Candidates[0].ID)

	_, err = fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	reviewerAfterGrant, err := fixture.service.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "task-detail",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, reviewerAfterGrant.Candidates, 1)
	require.Equal(t, fixture.agentID, reviewerAfterGrant.Candidates[0].ID)
	require.NotNil(t, reviewerAfterGrant.Candidates[0].OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, reviewerAfterGrant.Candidates[0].OwnerProfile.MemberID)
	require.Equal(t, fixture.ownerUserID, reviewerAfterGrant.Candidates[0].OwnerProfile.PrincipalID)
	require.Equal(t, fixture.ownerMemberID, reviewerAfterGrant.Candidates[0].OwnerMemberID)
	require.Equal(t, "collaborator", reviewerAfterGrant.Candidates[0].Relation)
	require.NotNil(t, reviewerAfterGrant.Candidates[0].RelationSource)
	require.Equal(t, "channel", reviewerAfterGrant.Candidates[0].RelationSource.ScopeType)
	require.Equal(t, fixture.channelID, reviewerAfterGrant.Candidates[0].RelationSource.ScopeID)
}

func TestListPrivateAgentMentionCandidatesKeepsQueryCountBounded(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	markTaskboardFixtureAgentPrivate(t, ctx, fixture)

	_, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	singleCountingDB := &taskboardQueryCountingDB{DB: fixture.pool}
	singleCountingService := New(singleCountingDB)
	single, err := singleCountingService.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "task-detail",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Len(t, single.Candidates, 1)
	singleQueries := singleCountingDB.Queries()
	require.Positive(t, singleQueries)

	for index := 0; index < 30; index++ {
		ownerMemberID := fmt.Sprintf("mem_private_candidate_owner_%02d", index)
		seedTaskboardHumanMember(t, ctx, fixture, fmt.Sprintf("usr_private_candidate_owner_%02d", index), ownerMemberID, fmt.Sprintf("Private Candidate Owner %02d", index))
		seedTaskboardPrivateAgent(t, ctx, fixture, fmt.Sprintf("agt_private_candidate_%02d", index), fmt.Sprintf("mem_private_candidate_agent_%02d", index), fmt.Sprintf("load-test-agent-%02d", index), fmt.Sprintf("Load Test Agent %02d", index), ownerMemberID)
	}

	countingDB := &taskboardQueryCountingDB{DB: fixture.pool}
	countingService := New(countingDB)
	bulk, err := countingService.ListPrivateAgentMentionCandidates(ctx, ListPrivateAgentMentionCandidatesRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
		Prefix: "load-test-agent",
		Limit:  8,
	})
	require.NoError(t, err)
	require.Empty(t, bulk.Candidates)
	require.LessOrEqual(t, countingDB.Queries(), singleQueries+1, "private-agent mention candidates must batch owner grant checks instead of querying per candidate owner")
}

func TestResolvePrivateAgentContextStateRejectsUnboundedMessageIDs(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Reject unbounded private-agent context request"},
	})
	require.NoError(t, err)

	messageIDs := make([]string, maxPrivateAgentContextStateWindowLimit+1)
	for index := range messageIDs {
		messageIDs[index] = fmt.Sprintf("msg_ctx_over_limit_%03d", index)
	}
	_, err = fixture.service.ResolvePrivateAgentContextState(ctx, ResolvePrivateAgentContextStateRequest{
		Context:    taskboardUserContext(fixture),
		Task:       TaskRef{TaskID: created.Tasks[0].ID},
		MessageIDs: messageIDs,
	})
	require.Error(t, err)
	require.ErrorIs(t, err, ErrBadRequest)
}

func TestResolvePrivateAgentContextStateDoesNotUseWorkspaceWideVisibleThreadEnumeration(t *testing.T) {
	_, file, _, ok := runtime.Caller(0)
	require.True(t, ok)
	resolverPath := filepath.Join(filepath.Dir(file), "private_agent_context_state.go")
	payload, err := os.ReadFile(resolverPath)
	require.NoError(t, err)
	content := string(payload)
	require.NotContains(t, content, "visible_threads", "private-agent context state must not enumerate every visible thread")
	require.NotContains(t, content, "loadPrivateAgentContextExplicitShares", "private-agent context state must not depend on removed explicit-share sources")
}

func TestTaskCollaboratorNotificationQueueFailureIsExplicit(t *testing.T) {
	ctx := context.Background()
	err := queueCollaboratorGrantAddedNotification(ctx, failingTaskCollaboratorNotificationExecer{}, "tcl_notification_failure", time.Now().UTC())
	require.Error(t, err)
	require.Contains(t, err.Error(), "queue collaborator grant notification")
}

type failingTaskCollaboratorNotificationExecer struct{}

func (failingTaskCollaboratorNotificationExecer) Query(context.Context, string, ...any) (coredb.Rows, error) {
	return nil, errors.New("unused")
}

func (failingTaskCollaboratorNotificationExecer) QueryRow(context.Context, string, ...any) coredb.Row {
	return failingTaskCollaboratorNotificationRow{}
}

func (failingTaskCollaboratorNotificationExecer) Exec(context.Context, string, ...any) (coredb.Result, error) {
	return nil, errors.New("notification outbox unavailable")
}

type failingTaskCollaboratorNotificationRow struct{}

func (failingTaskCollaboratorNotificationRow) Scan(...any) error {
	return errors.New("unused")
}

type taskboardQueryCountingDB struct {
	coredb.DB
	queryCount int
	queries    []string
}

func (db *taskboardQueryCountingDB) Query(ctx context.Context, query string, args ...any) (coredb.Rows, error) {
	db.recordQuery(query)
	return db.DB.Query(ctx, query, args...)
}

func (db *taskboardQueryCountingDB) QueryRow(ctx context.Context, query string, args ...any) coredb.Row {
	db.recordQuery(query)
	return db.DB.QueryRow(ctx, query, args...)
}

func (db *taskboardQueryCountingDB) BeginTx(ctx context.Context, options coredb.TxOptions) (coredb.Tx, error) {
	tx, err := db.DB.BeginTx(ctx, options)
	if err != nil {
		return nil, err
	}
	return taskboardQueryCountingTx{Tx: tx, recorder: db.recordQuery}, nil
}

func (db *taskboardQueryCountingDB) recordQuery(query string) {
	db.queryCount++
	db.queries = append(db.queries, query)
}

func (db *taskboardQueryCountingDB) Queries() int {
	return db.queryCount
}

func (db *taskboardQueryCountingDB) QueriesContaining(fragment string) []string {
	if strings.TrimSpace(fragment) == "" {
		return nil
	}
	matches := make([]string, 0)
	for _, query := range db.queries {
		if strings.Contains(query, fragment) {
			matches = append(matches, query)
		}
	}
	return matches
}

func (db *taskboardQueryCountingDB) Reset() {
	db.queryCount = 0
	db.queries = nil
}

type taskboardQueryCountingTx struct {
	coredb.Tx
	recorder func(string)
}

func (tx taskboardQueryCountingTx) Query(ctx context.Context, query string, args ...any) (coredb.Rows, error) {
	tx.recorder(query)
	return tx.Tx.Query(ctx, query, args...)
}

func (tx taskboardQueryCountingTx) QueryRow(ctx context.Context, query string, args ...any) coredb.Row {
	tx.recorder(query)
	return tx.Tx.QueryRow(ctx, query, args...)
}

func TestTaskCollaboratorRemoveAndLeaveDoNotQueueTransitionNotificationsInV1(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"No transition collaborator notifications"},
	})
	require.NoError(t, err)
	taskID := created.Tasks[0].ID

	added, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	_, err = fixture.service.LeaveTaskCollaborator(ctx, LeaveTaskCollaboratorRequest{
		Context: NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName),
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)

	var transitionCount int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_participant_metadata
WHERE id = $1
  AND add_notification_state <> 'queued'
`, added.Collaborator.ID).Scan(&transitionCount))
	require.Equal(t, int64(0), transitionCount)

	addedAgain, err := fixture.service.AddTaskCollaborator(ctx, AddTaskCollaboratorRequest{
		Context: taskboardUserContext(fixture),
		Task:    TaskRef{TaskID: taskID},
		Collaborator: TaskCollaboratorInput{
			MemberID: fixture.reviewerMemberID,
			Role:     taskCollaboratorRoleContributor,
		},
	})
	require.NoError(t, err)

	_, err = fixture.service.RemoveTaskCollaborator(ctx, RemoveTaskCollaboratorRequest{
		Context:  taskboardUserContext(fixture),
		Task:     TaskRef{TaskID: taskID},
		MemberID: fixture.reviewerMemberID,
	})
	require.NoError(t, err)

	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_participant_metadata
WHERE id IN ($1, $2)
  AND add_notification_state = 'queued'
`, added.Collaborator.ID, addedAgain.Collaborator.ID).Scan(&transitionCount))
	require.Equal(t, int64(2), transitionCount)
}

func seedTaskCollaboratorObserver(t *testing.T, ctx context.Context, fixture taskboardUserFixture) (string, string) {
	t.Helper()

	now := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
	userID := "usr_task_collaborator_observer"
	memberID := "mem_task_collaborator_observer"
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, 'task-collaborator-observer@example.com', 'task-collaborator-observer', 'Task Collaborator Observer', '', FALSE, 'hash', $2, $2, $2)
`, userID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', 'Task Collaborator Observer', $3, 'member', 'active', $4, $4)
`, memberID, fixture.workspaceID, userID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $3, $3)
`, fixture.channelID, memberID, now)
	return memberID, userID
}

func seedTaskCollaboratorBatchMember(t *testing.T, ctx context.Context, fixture taskboardUserFixture, index int) string {
	t.Helper()

	now := time.Date(2026, 5, 5, 11, 0, 0, 0, time.UTC).Add(time.Duration(index) * time.Second)
	userID := fmt.Sprintf("usr_task_collaborator_batch_%02d", index)
	memberID := fmt.Sprintf("mem_task_collaborator_batch_%02d", index)
	displayName := fmt.Sprintf("Task Collaborator Batch %02d", index)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', FALSE, 'hash', $5, $5, $5)
`, userID, fmt.Sprintf("task-collaborator-batch-%02d@example.com", index), fmt.Sprintf("task-collaborator-batch-%02d", index), displayName, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, memberID, fixture.workspaceID, displayName, userID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $3, $3)
`, fixture.channelID, memberID, now)
	return memberID
}

func seedTaskCollaboratorStandaloneMember(t *testing.T, ctx context.Context, fixture taskboardUserFixture, suffix string) string {
	t.Helper()

	normalizedSuffix := strings.ReplaceAll(strings.TrimSpace(suffix), "-", "_")
	require.NotEmpty(t, normalizedSuffix)
	now := time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)
	userID := "usr_task_collaborator_" + normalizedSuffix
	memberID := "mem_task_collaborator_" + normalizedSuffix
	displayName := "Task Collaborator " + strings.ReplaceAll(normalizedSuffix, "_", " ")
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', FALSE, 'hash', $5, $5, $5)
`, userID, fmt.Sprintf("task-collaborator-%s@example.com", strings.ReplaceAll(normalizedSuffix, "_", "-")), "task-collaborator-"+strings.ReplaceAll(normalizedSuffix, "_", "-"), displayName, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, memberID, fixture.workspaceID, displayName, userID, now)
	return memberID
}

func insertTaskCollaboratorSourceMessage(t *testing.T, ctx context.Context, fixture taskboardUserFixture, messageID string, authorMemberID string, seq int64, globalSeq int64, content string, createdAt time.Time) {
	t.Helper()

	insertTaskCollaboratorSourceMessageWithAuthorType(t, ctx, fixture, messageID, authorMemberID, "human", seq, globalSeq, content, createdAt)
}

func insertTaskCollaboratorSourceMessageWithAuthorType(t *testing.T, ctx context.Context, fixture taskboardUserFixture, messageID string, authorMemberID string, authorType string, seq int64, globalSeq int64, content string, createdAt time.Time) {
	t.Helper()

	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_messages (
  id, workspace_id, scope_id, seq, workspace_global_seq, author_member_id,
  author_type, message_type, content, metadata, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, '', $8, '{}', $9, $9)
`, messageID, fixture.workspaceID, fixture.channelID, seq, globalSeq, authorMemberID, authorType, content, createdAt)
}

func privateAgentContextStatesByMessage(states []PrivateAgentContextState) map[string]PrivateAgentContextState {
	byMessage := make(map[string]PrivateAgentContextState, len(states))
	for _, state := range states {
		byMessage[state.MessageID] = state
	}
	return byMessage
}

func collaboratorSummaryByMember(summaries []AgentCollaboratorSummary, memberID string) AgentCollaboratorSummary {
	for _, summary := range summaries {
		if summary.MemberID == memberID {
			return summary
		}
	}
	return AgentCollaboratorSummary{}
}

func seedTaskCollaboratorReplyThread(t *testing.T, ctx context.Context, fixture taskboardUserFixture) string {
	t.Helper()

	now := time.Date(2026, 5, 3, 13, 0, 0, 0, time.UTC)
	threadChannelID := "chn_task_collaborator_thread"
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, parent_scope_id, root_message_id, root_message_short_id,
  identity_key, latest_seq, latest_global_seq, latest_message_at, status,
  created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'thread', $3, 'msg_task_collaborator_parent', 'task-collab', $4, 0, 0, NULL, 'active', $5, $6, $6)
`, threadChannelID, fixture.workspaceID, fixture.channelID, "thread:msg_task_collaborator_parent", fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $4, $4), ($1, $3, 'public', NULL, $4, $4)
`, threadChannelID, fixture.ownerMemberID, fixture.reviewerMemberID, now)
	return threadChannelID
}

func seedTaskCollaboratorDMReplyThread(t *testing.T, ctx context.Context, fixture taskboardUserFixture, memberID string) (string, string) {
	t.Helper()

	now := time.Date(2026, 5, 3, 14, 0, 0, 0, time.UTC)
	dmThreadID := "dm_task_collaborator_parent"
	threadChannelID := "chn_task_collaborator_dm_thread"
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
  latest_message_at, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'dm', $3, 0, 0, NULL, 'active', $4, $5, $5)
`, dmThreadID, fixture.workspaceID, "dm:task-collaborator-parent", fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $4, $4), ($1, $3, 'public', NULL, $4, $4)
`, dmThreadID, fixture.ownerMemberID, memberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scopes (
  id, workspace_id, type, parent_scope_id, root_message_id, root_message_short_id,
  identity_key, latest_seq, latest_global_seq, latest_message_at, status,
  created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'thread', $3, 'msg_dm_task_collaborator_parent', 'dm-collab', $4, 0, 0, NULL, 'active', $5, $6, $6)
`, threadChannelID, fixture.workspaceID, dmThreadID, "thread:msg_dm_task_collaborator_parent", fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $4, $4), ($1, $3, 'public', NULL, $4, $4)
`, threadChannelID, fixture.ownerMemberID, memberID, now)
	return threadChannelID, dmThreadID
}

func markTaskboardFixtureAgentPrivate(t *testing.T, ctx context.Context, fixture taskboardUserFixture) {
	t.Helper()

	mustExecTaskboard(t, ctx, fixture.pool, `
UPDATE scope_memberships
SET visibility = 'private',
    owner_member_id = $3,
    updated_at = $4
WHERE scope_id = $2
  AND member_id = $5
`, fixture.workspaceID, fixture.channelID, fixture.ownerMemberID, time.Now().UTC(), fixture.agentMemberID)
}

func seedTaskboardHumanMember(t *testing.T, ctx context.Context, fixture taskboardUserFixture, userID string, memberID string, displayName string) {
	t.Helper()

	now := time.Date(2026, 5, 3, 15, 0, 0, 0, time.UTC)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, '', FALSE, 'hash', $5, $5, $5)
`, userID, userID+"@example.com", userID, displayName, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, memberID, fixture.workspaceID, displayName, userID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'public', NULL, $3, $3)
`, fixture.channelID, memberID, now)
}

func seedTaskboardPrivateAgent(t *testing.T, ctx context.Context, fixture taskboardUserFixture, agentID string, agentMemberID string, agentName string, agentDisplayName string, ownerMemberID string) string {
	t.Helper()

	now := time.Date(2026, 5, 3, 15, 0, 0, 0, time.UTC)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, status, created_at, updated_at)
VALUES ($1, $2, 'agent', $3, 'active', $4, $4)
`, agentMemberID, fixture.workspaceID, agentDisplayName, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, reasoning_effort, description, session_id, auto_wake_enabled, status, activity, activity_detail, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, '', 'codex', 'gpt-5.4', '', '', '', TRUE, 'active', 'online', '', $6, $6)
`, agentID, fixture.workspaceID, fixture.machineID, agentMemberID, agentName, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO scope_memberships (scope_id, member_id, visibility, owner_member_id, created_at, updated_at)
VALUES ($1, $2, 'private', $3, $4, $4)
`, fixture.channelID, agentMemberID, ownerMemberID, now)
	return agentID
}

func seedTaskCollaboratorAppInstallation(t *testing.T, ctx context.Context, fixture taskboardUserFixture, suffix string) string {
	t.Helper()

	normalizedSuffix := strings.ReplaceAll(strings.TrimSpace(suffix), "-", "_")
	require.NotEmpty(t, normalizedSuffix)
	now := time.Date(2026, 5, 3, 16, 0, 0, 0, time.UTC)
	memberID := "mem_task_collaborator_app_" + normalizedSuffix
	serviceInstallationID := "svcinst_task_collaborator_app_" + normalizedSuffix
	serviceMemberID := "svcmem_task_collaborator_app_" + normalizedSuffix
	avatarURL := taskCollaboratorAppAvatarURL(normalizedSuffix)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, status, created_at, updated_at)
VALUES ($1, $2, 'service', 'GitHub', 'active', $3, $3)
`, memberID, fixture.workspaceID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO service_installations (
  id, workspace_id, service_definition_id, service_kind, status, installed_version,
  config, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'app_github', 'app', 'active', '1.0.0', '{}', $3, $4, $4)
`, serviceInstallationID, fixture.workspaceID, fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO service_members (
  id, workspace_id, service_definition_id, service_installation_id, member_id,
  service_kind, handle, display_name, avatar_url, status, created_at, updated_at
) VALUES ($1, $2, 'app_github', $3, $4, 'app', 'github', 'GitHub', $5, 'active', $6, $6)
`, serviceMemberID, fixture.workspaceID, serviceInstallationID, memberID, avatarURL, now)
	return memberID
}

func seedTaskCollaboratorExternalApp(
	t *testing.T,
	ctx context.Context,
	fixture taskboardUserFixture,
	suffix string,
	catalogVisibility string,
) (string, string) {
	t.Helper()

	normalizedSuffix := strings.ReplaceAll(strings.TrimSpace(suffix), "-", "_")
	require.NotEmpty(t, normalizedSuffix)
	require.Contains(t, []string{"private", "public"}, catalogVisibility)
	now := time.Date(2026, 5, 3, 17, 0, 0, 0, time.UTC)
	appID := "app_task_collaborator_bot_" + normalizedSuffix
	appSlug := "task-collaborator-app-" + strings.ReplaceAll(normalizedSuffix, "_", "-")
	appVersionID := "appver_task_collaborator_bot_" + normalizedSuffix
	memberID := "mem_task_collaborator_bot_" + normalizedSuffix
	serviceInstallationID := "svcinst_task_collaborator_bot_" + normalizedSuffix
	serviceMemberID := "svcmem_task_collaborator_bot_" + normalizedSuffix
	manifest := `{"capabilities":{"runtime":{"runtime":{"transports":["runner_stream"],"default_transport":"runner_stream"},"events":["message.mention"],"scopes":["messages:read","messages:write"]}}}`
	metadata := `{"manifest":` + manifest + `}`

	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO members (id, workspace_id, type, display_name, status, created_at, updated_at)
VALUES ($1, $2, 'service', 'Public authority App', 'active', $3, $3)
`, memberID, fixture.workspaceID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO service_definitions (
  id, service_kind, installation_mode, slug, name, description, avatar_url,
  visibility, developer_id, latest_version, manifest, config, status,
  created_at, updated_at
) VALUES ($1, 'app', 'singleton', $2, 'Public authority App',
          'App collaborator authority test', '', 'private', '', '1.0.0', $3,
          '{"appKind":"third_party"}', 'active', $4, $4)
`, appID, appSlug, manifest, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO extension_definition_versions (id, service_definition_id, version, manifest, callback_url, created_at, updated_at)
VALUES ($1, $2, '1.0.0', $3, '', $4, $4)
`, appVersionID, appID, manifest, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO extension_catalog_attributions (
  workspace_id, service_definition_id, created_by_member_id, catalog_visibility, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $5)
`, fixture.workspaceID, appID, fixture.reviewerMemberID, catalogVisibility, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO service_installations (
  id, workspace_id, service_definition_id, service_kind, status,
  installed_version, config, metadata, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, $3, 'app', 'active', '1.0.0', '{}',
	          $4, $5, $6, $6)
`, serviceInstallationID, fixture.workspaceID, appID, metadata, fixture.ownerMemberID, now)
	mustExecTaskboard(t, ctx, fixture.pool, `
INSERT INTO service_members (
  id, workspace_id, service_definition_id, service_installation_id, member_id,
  service_kind, handle, display_name, avatar_url, status, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'app', $6, 'Public authority App', '', 'active', $7, $7)
`, serviceMemberID, fixture.workspaceID, appID, serviceInstallationID, memberID, appSlug, now)
	return memberID, appID
}

func taskCollaboratorAppAvatarURL(suffix string) string {
	normalizedSuffix := strings.ReplaceAll(strings.TrimSpace(suffix), "-", "_")
	return "https://example.test/github-" + normalizedSuffix + ".png"
}

func taskCollaboratorAppPrincipalID(suffix string) string {
	normalizedSuffix := strings.ReplaceAll(strings.TrimSpace(suffix), "-", "_")
	return "svcinst_task_collaborator_app_" + normalizedSuffix
}

func countTaskScopeExecutionRows(t *testing.T, ctx context.Context, fixture taskboardUserFixture) int64 {
	t.Helper()

	var count int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_executions
WHERE workspace_id = $1
`, fixture.workspaceID).Scan(&count))
	return count
}

func countTaskScopePeopleRows(t *testing.T, ctx context.Context, fixture taskboardUserFixture) int64 {
	t.Helper()

	var count int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_executions tse
JOIN task_scope_participant_metadata tspm
  ON tspm.owner_scope_execution_id = tse.id
 AND tspm.removed_at IS NULL
 AND tspm.left_at IS NULL
 AND (tspm.expires_at IS NULL OR tspm.expires_at > CURRENT_TIMESTAMP)
WHERE tse.workspace_id = $1
`, fixture.workspaceID).Scan(&count))
	return count
}

func countTaskScopeOwnerPeopleRows(t *testing.T, ctx context.Context, fixture taskboardUserFixture) int64 {
	t.Helper()

	var count int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM task_scope_executions tse
JOIN task_scope_participant_metadata tspm
  ON tspm.owner_scope_execution_id = tse.id
 AND tspm.member_id = $2
 AND tspm.removed_at IS NULL
 AND tspm.left_at IS NULL
 AND (tspm.expires_at IS NULL OR tspm.expires_at > CURRENT_TIMESTAMP)
WHERE tse.workspace_id = $1
`, fixture.workspaceID, fixture.ownerMemberID).Scan(&count))
	return count
}

func countTaskDiscussionEntries(t *testing.T, ctx context.Context, fixture taskboardUserFixture, taskID string) int64 {
	t.Helper()

	var count int64
	require.NoError(t, fixture.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM tasks t
JOIN scope_messages sm
  ON sm.scope_id = t.task_scope_id
 AND sm.message_type = 'task_discussion'
WHERE t.id = $1
`, taskID).Scan(&count))
	return count
}

func timePointer(value time.Time) *time.Time {
	return &value
}

func TestListTaskCollaboratorsCarriesServerDerivedOwnerOnEmptyLists(t *testing.T) {
	ctx := context.Background()
	fixture := seedTaskboardUserFixture(t, ctx)
	threadChannelID := seedTaskCollaboratorReplyThread(t, ctx, fixture)

	created, err := fixture.service.CreateTasks(ctx, CreateTasksRequest{
		Context: taskboardUserContext(fixture),
		Board:   BoardRef{ChannelID: fixture.channelID},
		Titles:  []string{"Carry authoritative owner on empty collaborator list"},
	})
	require.NoError(t, err)
	require.Len(t, created.Tasks, 1)
	taskID := created.Tasks[0].ID

	// The viewer is a plain member, never the owner. Every surface below must
	// resolve the authoritative execution owner server-side and carry it on the
	// response even when the collaborator list is empty.
	reviewerCtx := NewUserServiceContext(fixture.workspaceID, fixture.reviewerUserID, fixture.reviewerDisplayName)

	// Task surface: the anchor (creator) is the owner, not the viewer.
	taskList, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context: reviewerCtx,
		Task:    TaskRef{TaskID: taskID},
	})
	require.NoError(t, err)
	require.Empty(t, taskList.Collaborators)
	require.Equal(t, fixture.ownerMemberID, taskList.ExecutionOwnerMemberID)
	require.NotEqual(t, fixture.reviewerMemberID, taskList.ExecutionOwnerMemberID)
	require.NotNil(t, taskList.OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, taskList.OwnerProfile.MemberID)
	require.Equal(t, fixture.ownerUserID, taskList.Owner.ID)

	// Thread surface: natural owner (thread creator) with no owner parameter.
	threadList, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context: reviewerCtx,
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "thread",
			ScopeID:   threadChannelID,
		},
	})
	require.NoError(t, err)
	require.Empty(t, threadList.Collaborators)
	require.Equal(t, fixture.ownerMemberID, threadList.ExecutionOwnerMemberID)
	require.NotEqual(t, fixture.reviewerMemberID, threadList.ExecutionOwnerMemberID)
	require.NotNil(t, threadList.OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, threadList.OwnerProfile.MemberID)

	// Channel surface: natural owner (channel creator; profile owner is NULL).
	channelList, err := fixture.service.ListTaskCollaborators(ctx, ListTaskCollaboratorsRequest{
		Context: reviewerCtx,
		Scope: TaskCollaboratorScopeRef{
			ScopeType: "channel",
			ScopeID:   fixture.channelID,
		},
	})
	require.NoError(t, err)
	require.Empty(t, channelList.Collaborators)
	require.Equal(t, fixture.ownerMemberID, channelList.ExecutionOwnerMemberID)
	require.NotEqual(t, fixture.reviewerMemberID, channelList.ExecutionOwnerMemberID)
	require.NotNil(t, channelList.OwnerProfile)
	require.Equal(t, fixture.ownerMemberID, channelList.OwnerProfile.MemberID)
}
