package approvalapp

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/im/fanout"
	"github.com/tidbcloud/AgentTeam/internal/core/runeffect"
	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/service/agentapproval"
	"github.com/tidbcloud/AgentTeam/internal/service/appruntime"
	serviceaccountflow "github.com/tidbcloud/AgentTeam/internal/service/serviceaccount"
	"github.com/tidbcloud/AgentTeam/internal/service/team/roleproposal"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/agentops"
	"github.com/tidbcloud/AgentTeam/internal/store/app"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/approval"
	"github.com/tidbcloud/AgentTeam/internal/store/im"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
	serviceaccountstore "github.com/tidbcloud/AgentTeam/internal/store/serviceaccount"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
	"github.com/tidbcloud/AgentTeam/internal/store/team"
	"github.com/tidbcloud/AgentTeam/internal/store/testfixture"
	"github.com/tidbcloud/AgentTeam/internal/unread"
)

type projectionLimitRecordingNotificationStore struct {
	NotificationStore
	resolved     app.AppInstallationRequestView
	listLimits   []int
	activeLimits []int
	createCount  int
}

func (store *projectionLimitRecordingNotificationStore) SettlePrivilegedAppInstallationRequest(
	context.Context,
	string,
	string,
) (app.AppInstallationRequestView, bool, error) {
	return store.resolved, false, nil
}

func (store *projectionLimitRecordingNotificationStore) CreateAppNotificationForBuiltin(
	_ context.Context,
	_, _ string,
	command app.AppNotificationCommand,
) (app.AppNotificationResult, error) {
	store.createCount++
	return app.AppNotificationResult{
		NotificationID: command.ClientNotificationID,
		MessageID:      command.ClientNotificationID,
	}, nil
}

func (store *projectionLimitRecordingNotificationStore) FindAppNotificationByClientID(
	context.Context,
	string,
	string,
	string,
) (app.AppNotificationRecord, bool, error) {
	return app.AppNotificationRecord{}, false, nil
}

func (store *projectionLimitRecordingNotificationStore) ListAppNotificationsByBusinessObject(
	_ context.Context,
	_, _, _, _, _ string,
	limit int,
) ([]app.AppNotificationRecord, error) {
	store.listLimits = append(store.listLimits, limit)
	return nil, nil
}

func (store *projectionLimitRecordingNotificationStore) ListActiveAppNotificationCardsByBusinessObject(
	_ context.Context,
	_, _, _, _, _, _ string,
	limit int,
) ([]app.AppNotificationRecord, error) {
	store.activeLimits = append(store.activeLimits, limit)
	return nil, nil
}

func newServiceAccountSubjectRegistry(
	t *testing.T,
	approvalStore *approval.Store,
	accounts *serviceaccountflow.Service,
	projector *Projector,
) *SubjectRegistry {
	t.Helper()
	subjects := NewSubjectRegistry(approvalStore)
	require.NoError(t, subjects.Register(NewServiceAccountCreateSubjectHandler(approvalStore, accounts, projector)))
	subjects.Seal()
	return subjects
}

type recordingWorkspaceAppRealtime struct {
	workspaceIDs []string
}

func (realtime *recordingWorkspaceAppRealtime) EmitWorkspaceAppsUpdated(workspaceID string) {
	realtime.workspaceIDs = append(realtime.workspaceIDs, workspaceID)
}

func TestApprovalAppWorkspaceCreateApproveRejectAndReplay(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_e2e"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{})
	conversations := message.New(db, nil, 0, message.Options{})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	accounts := serviceaccountflow.New(db)
	approvalStore := approval.New(db)
	subjects := newServiceAccountSubjectRegistry(t, approvalStore, accounts, projector)
	handler := NewHandler(approvalStore, notifications, projector, subjects)
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-e2e", ClaimLimit: 10,
	})

	pending := submitApprovalCreate(t, ctx, accounts, workspaceID, "approve", now)
	created, err := projector.ProjectPendingRequest(ctx, pending)
	require.NoError(t, err)
	require.Len(t, created, 1)
	replayed, err := projector.ProjectPendingRequest(ctx, pending)
	require.NoError(t, err)
	require.Len(t, replayed, 1)
	require.True(t, replayed[0].AlreadyCreated)
	require.Equal(t, created[0].NotificationID, replayed[0].NotificationID)
	pendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantReceiptClientNotificationID(pending.Request.ID),
		app.AppNotificationStatusActive, "服务号创建申请已提交：News approve",
	)
	require.Equal(t, int64(1), pendingReceipt.Revision)
	applicantRead, err := conversations.PerformConversationActionForUser(
		ctx, "usr_approval_bot_member", workspaceID, "dm:"+pendingReceipt.ScopeID,
		message.ConversationActionMarkRead,
	)
	require.NoError(t, err)
	require.Zero(t, applicantRead.UnreadCount)
	require.False(t, applicantRead.ManualUnread)
	require.Equal(t, pendingReceipt.MessageSeq, applicantRead.LastReadSeq)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notification_cards WHERE message_id = $1
`, pendingReceipt.MessageID), "Applicant pending receipts must render as cards")
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_TYPE(JSON_EXTRACT(action_schema_json, '$.actions')) = 'ARRAY'
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, pendingReceipt.MessageID), "Applicant pending cards must not expose actions")
	var projectedDescription string
	require.NoError(t, db.QueryRow(ctx, `
SELECT JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.request.description'))
FROM service_notification_cards
WHERE message_id = $1
`, pendingReceipt.MessageID).Scan(&projectedDescription))
	require.Equal(t, "Description approve", projectedDescription)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisApplicant, pending.Approval.ID))

	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))
	var installationMetadata []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT metadata
FROM service_installations
WHERE id = $1
`, installationID).Scan(&installationMetadata))
	require.JSONEq(t, approvalAppInstallationMetadataJSON, string(installationMetadata))
	var projectedAvatarURL string
	require.NoError(t, db.QueryRow(ctx, `
SELECT avatar_url
FROM service_members
WHERE workspace_id = $1
  AND service_installation_id = $2
  AND status = 'active'
`, workspaceID, installationID).Scan(&projectedAvatarURL))
	require.Equal(t, AvatarURL, projectedAvatarURL)
	require.Equal(t, int64(3), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_grants
WHERE service_installation_id = $1 AND revoked_at IS NULL
`, installationID))
	var policyVersion int64
	var policyDigest string
	var policyConfig []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT policy_version, policy_digest, config
FROM service_grants
WHERE service_installation_id = $1 AND scope = 'app.notifications.write' AND revoked_at IS NULL
`, installationID).Scan(&policyVersion, &policyDigest, &policyConfig))
	require.Equal(t, int64(2), policyVersion)
	require.Len(t, policyDigest, 64)
	require.Contains(t, string(policyConfig), `"approval_assignment"`)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications WHERE workspace_id = $1 AND id = $2
`, workspaceID, created[0].NotificationID))
	reviewerRead, err := conversations.PerformConversationActionForUser(
		ctx, "usr_approval_bot_admin", workspaceID, "dm:"+created[0].ScopeID,
		message.ConversationActionMarkRead,
	)
	require.NoError(t, err)
	require.Zero(t, reviewerRead.UnreadCount)
	require.Equal(t, created[0].MessageSeq, reviewerRead.LastReadSeq)

	action, err := notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, app.AppNotificationActionCommand{
		MessageID: created[0].MessageID, ActionKey: ActionApprove,
		CardRevision: 1, ClientActionID: "approve-create-request", Input: map[string]any{},
	})
	require.NoError(t, err)
	require.NotEmpty(t, action.ActionInvocationID)
	dispatched, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID, HandlerKey: HandlerKey,
		Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)

	approvedRequest, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, pending.Request.ID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.RequestStateFulfilled, approvedRequest.BusinessState)
	require.NotEmpty(t, approvedRequest.CreatedServiceAccountID)
	approvedAccount, err := serviceaccountstore.New(db).LoadAccount(ctx, workspaceID, approvedRequest.CreatedServiceAccountID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.AudienceStateReady, approvedAccount.AudienceState)
	require.Equal(t, int64(3), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM scope_memberships
WHERE scope_id = $1 AND effective_to_seq IS NULL AND removed_at IS NULL
`, approvedAccount.MessageScopeID))
	resolved, err := notifications.LoadAppNotification(ctx, workspaceID, created[0].NotificationID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusResolved, resolved.Status)
	require.Equal(t, "mem_approval_bot_admin", resolved.CausedByActorID)
	require.Equal(t, created[0].MessageID, resolved.MessageID)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisApplicant, pending.Approval.ID))
	resolvedReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantReceiptClientNotificationID(pending.Request.ID),
		app.AppNotificationStatusResolved, "服务号创建申请已通过：News approve",
	)
	require.Equal(t, int64(2), resolvedReceipt.Revision)
	require.Equal(t, pendingReceipt.NotificationID, resolvedReceipt.NotificationID)
	require.Equal(t, pendingReceipt.MessageID, resolvedReceipt.MessageID)
	require.Equal(t, pendingReceipt.MessageSeq, resolvedReceipt.MessageSeq)
	requireApprovalCardTemplate(t, ctx, db, resolvedReceipt.MessageID, "")
	approvedResult := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantResultClientNotificationID(pending.Request.ID),
		app.AppNotificationStatusActive, "你的服务号创建申请已通过：News approve",
	)
	require.Equal(t, int64(1), approvedResult.Revision)
	require.NotEqual(t, resolvedReceipt.NotificationID, approvedResult.NotificationID)
	require.NotEqual(t, resolvedReceipt.MessageID, approvedResult.MessageID)
	require.Equal(t, resolvedReceipt.ScopeID, approvedResult.ScopeID)
	require.Greater(t, approvedResult.MessageSeq, resolvedReceipt.MessageSeq)
	requireApprovalResultTemplate(t, ctx, db, approvedResult.MessageID, 0)
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID)
	requireNaturalConversationUnread(
		t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID,
		pendingReceipt.MessageSeq, 1,
	)
	requireNaturalConversationUnread(
		t, ctx, db, "mem_approval_bot_admin", created[0].ScopeID,
		created[0].MessageSeq, 0,
	)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM scope_fanout_outbox WHERE message_id = $1
`, approvedResult.MessageID), "fresh applicant result must use ordinary message fan-out")
	approvedInstance, err := approval.New(db).Load(ctx, workspaceID, pending.Approval.ID)
	require.NoError(t, err)
	mustExec(t, db, `UPDATE users SET display_name = 'Renamed approval processor' WHERE id = 'usr_approval_bot_admin'`)
	replayedApplicantResult, err := projector.projectApplicantResult(ctx, approvedRequest, approvedInstance, &approvedAccount)
	require.NoError(t, err)
	require.True(t, replayedApplicantResult.AlreadyCreated)
	require.Equal(t, approvedResult.NotificationID, replayedApplicantResult.NotificationID)
	require.Equal(t, approvedResult.MessageID, replayedApplicantResult.MessageID)
	replayedResultRecord, err := notifications.LoadAppNotification(ctx, workspaceID, approvedResult.NotificationID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusActive, replayedResultRecord.Status)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2 AND business_object_id = $3
`, workspaceID, RecipientBasisApplicant, pending.Approval.ID))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM scope_fanout_outbox WHERE message_id = $1
`, approvedResult.MessageID))
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID)

	rejected := submitApprovalCreate(t, ctx, accounts, workspaceID, "reject", now.Add(time.Minute))
	rejectCards, err := projector.ProjectPendingRequest(ctx, rejected)
	require.NoError(t, err)
	require.Len(t, rejectCards, 1)
	_, err = notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, app.AppNotificationActionCommand{
		MessageID: rejectCards[0].MessageID, ActionKey: ActionReject,
		CardRevision: 1, ClientActionID: "reject-create-request",
		Input: map[string]any{"reason": "not approved"},
	})
	require.NoError(t, err)
	dispatched, err = dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID, HandlerKey: HandlerKey,
		Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	declined, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, rejected.Request.ID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.RequestStateDeclined, declined.BusinessState)
	require.Empty(t, declined.CreatedServiceAccountID)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisApplicant, rejected.Approval.ID))
	rejectedReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantReceiptClientNotificationID(rejected.Request.ID),
		app.AppNotificationStatusResolved, "服务号创建申请已拒绝：News reject",
	)
	requireApprovalCardTemplate(t, ctx, db, rejectedReceipt.MessageID, "")
	rejectedResult := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantResultClientNotificationID(rejected.Request.ID),
		app.AppNotificationStatusActive, "你的服务号创建申请已拒绝：News reject",
	)
	require.NotEqual(t, rejectedReceipt.MessageID, rejectedResult.MessageID)
	require.Greater(t, rejectedResult.MessageSeq, rejectedReceipt.MessageSeq)
	requireApprovalResultTemplate(t, ctx, db, rejectedResult.MessageID, 0)
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", rejectedResult.ScopeID)

	inactive := submitApprovalCreate(t, ctx, accounts, workspaceID, "inactive", now.Add(2*time.Minute))
	inactiveCards, err := projector.ProjectPendingRequest(ctx, inactive)
	require.NoError(t, err)
	require.Len(t, inactiveCards, 1)
	inactivePendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantReceiptClientNotificationID(inactive.Request.ID),
		app.AppNotificationStatusActive, "服务号创建申请已提交：News inactive",
	)
	mustExec(t, db, `UPDATE members SET status = 'inactive' WHERE id = 'mem_approval_bot_member'`)
	_, err = notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, app.AppNotificationActionCommand{
		MessageID: inactiveCards[0].MessageID, ActionKey: ActionReject,
		CardRevision: 1, ClientActionID: "reject-inactive-create-request",
		Input: map[string]any{"reason": "requester left workspace"},
	})
	require.NoError(t, err)
	dispatched, err = dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID, HandlerKey: HandlerKey,
		Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed, "inactive applicant must not keep the committed decision delivery retrying")
	require.Zero(t, dispatched.Retried)
	inactiveDeclined, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, inactive.Request.ID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.RequestStateDeclined, inactiveDeclined.BusinessState)
	inactiveResolvedReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx, workspaceID, installationID, applicantReceiptClientNotificationID(inactive.Request.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, app.AppNotificationStatusResolved, inactiveResolvedReceipt.Status)
	require.Equal(t, inactivePendingReceipt.NotificationID, inactiveResolvedReceipt.ID)
	require.Equal(t, inactivePendingReceipt.MessageID, inactiveResolvedReceipt.MessageID)
	inactiveResolvedResult, err := notifications.GetAppNotificationForBuiltin(
		ctx, IdentityID, workspaceID, inactiveResolvedReceipt.ID,
	)
	require.NoError(t, err)
	require.Equal(t, inactivePendingReceipt.MessageSeq, inactiveResolvedResult.MessageSeq)
	_, found, err = notifications.FindAppNotificationByClientID(
		ctx, workspaceID, installationID, applicantResultClientNotificationID(inactive.Request.ID),
	)
	require.NoError(t, err)
	require.False(t, found, "inactive applicant must not receive a fresh Human-DM result")
	inactiveReviewer, err := notifications.LoadAppNotification(ctx, workspaceID, inactiveCards[0].NotificationID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusResolved, inactiveReviewer.Status)
}

func TestApprovalAppPromotedServiceAccountRequesterReconcilesProjectedCardsIdempotently(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_promoted_reconcile"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	accounts := serviceaccountflow.New(db)
	promoted := submitApprovalCreate(t, ctx, accounts, workspaceID, "promoted-reconcile", now)
	cards, err := projector.ProjectPendingRequest(ctx, promoted)
	require.NoError(t, err)
	require.Len(t, cards, 1)
	receipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		applicantReceiptClientNotificationID(promoted.Request.ID),
		app.AppNotificationStatusActive, "服务号创建申请已提交：News promoted-reconcile",
	)

	ordinary, err := accounts.SubmitWorkspaceCreateRequest(ctx, serviceaccountflow.SubmitWorkspaceCreateRequest{
		WorkspaceID: workspaceID, RequesterUserID: "usr_approval_bot_reader",
		ClientRequestID: "create-ordinary-control",
		Payload:         serviceaccountflow.WorkspaceCreatePayload{Name: "Ordinary Control"},
		Now:             now.Add(time.Second),
	})
	require.NoError(t, err)
	require.NotNil(t, ordinary.Approval)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE workspace_id = $1 AND id = 'mem_approval_bot_member'`, workspaceID)

	reconciled, err := accounts.ReconcilePrivilegedWorkspaceCreateRequests(ctx, workspaceID, 100, now.Add(time.Minute))
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	cancelled, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, promoted.Request.ID)
	require.NoError(t, err)
	require.True(t, serviceaccountflow.IsPrivilegedWorkspaceCreateCancellation(cancelled))
	ordinaryPersisted, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, ordinary.Request.ID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.RequestStateAwaitingAuthorization, ordinaryPersisted.BusinessState)

	for _, notificationID := range []string{cards[0].NotificationID, receipt.NotificationID} {
		notification, loadErr := notifications.LoadAppNotification(ctx, workspaceID, notificationID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppNotificationStatusWithdrawn, notification.Status)
		require.EqualValues(t, 2, notification.Revision)
		var cardState string
		var cardRevision int64
		require.NoError(t, db.QueryRow(ctx, `SELECT state, card_revision FROM service_notification_cards WHERE service_notification_id = $1`, notificationID).Scan(&cardState, &cardRevision))
		require.Equal(t, "withdrawn", cardState)
		require.EqualValues(t, 2, cardRevision)
	}
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND client_notification_id = $2
`, workspaceID, applicantResultClientNotificationID(promoted.Request.ID)))
	require.Equal(t, int64(0), rowCount(t, ctx, db, `SELECT COUNT(*) FROM service_accounts WHERE workspace_id = $1`, workspaceID))

	reconciled, err = accounts.ReconcilePrivilegedWorkspaceCreateRequests(ctx, workspaceID, 100, now.Add(2*time.Minute))
	require.NoError(t, err)
	require.Zero(t, reconciled)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE id IN ($1, $2) AND status = 'withdrawn' AND revision = 2
`, cards[0].NotificationID, receipt.NotificationID))
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notification_cards
WHERE service_notification_id IN ($1, $2) AND state = 'withdrawn' AND card_revision = 2
`, cards[0].NotificationID, receipt.NotificationID))
}

func TestApprovalAppPromotedServiceAccountRequesterActionWithdrawsBeforeDecision(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 16, 12, 30, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_promoted_action"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	accounts := serviceaccountflow.New(db)
	approvalStore := approval.New(db)
	subjects := newServiceAccountSubjectRegistry(t, approvalStore, accounts, projector)
	handler := NewHandler(approvalStore, notifications, projector, subjects)
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-promoted-action", ClaimLimit: 10,
	})
	pending := submitApprovalCreate(t, ctx, accounts, workspaceID, "promoted-action", now)
	cards, err := projector.ProjectPendingRequest(ctx, pending)
	require.NoError(t, err)
	require.Len(t, cards, 1)
	_, err = notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, app.AppNotificationActionCommand{
		MessageID: cards[0].MessageID, ActionKey: ActionApprove,
		CardRevision: 1, ClientActionID: "approve-before-applicant-promotion", Input: map[string]any{},
	})
	require.NoError(t, err)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE workspace_id = $1 AND id = 'mem_approval_bot_member'`, workspaceID)

	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))
	dispatched, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	require.Zero(t, dispatched.Retried)

	cancelled, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, pending.Request.ID)
	require.NoError(t, err)
	require.True(t, serviceaccountflow.IsPrivilegedWorkspaceCreateCancellation(cancelled))
	require.Empty(t, cancelled.CreatedServiceAccountID)
	instance, err := approval.New(db).Load(ctx, workspaceID, pending.Approval.ID)
	require.NoError(t, err)
	require.Equal(t, approval.StateCancelled, instance.State, "role fence must run before generic Decide")
	notification, err := notifications.LoadAppNotification(ctx, workspaceID, cards[0].NotificationID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusWithdrawn, notification.Status)
	require.EqualValues(t, 2, notification.Revision)
	applicantReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx, workspaceID, installationID, applicantReceiptClientNotificationID(pending.Request.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, app.AppNotificationStatusWithdrawn, applicantReceipt.Status)
	require.EqualValues(t, 2, applicantReceipt.Revision)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notification_cards
WHERE service_notification_id IN ($1, $2) AND state = 'withdrawn' AND card_revision = 2
`, cards[0].NotificationID, applicantReceipt.ID))
	var effectKind, effectRef, effectMessageID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT effect_kind, effect_ref, COALESCE(message_id, '')
FROM service_event_delivery_effects
WHERE workspace_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 1
`, workspaceID).Scan(&effectKind, &effectRef, &effectMessageID))
	require.Equal(t, "message_withdrawal", effectKind)
	require.Equal(t, cards[0].NotificationID, effectRef)
	require.Equal(t, cards[0].MessageID, effectMessageID)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND client_notification_id = $2
`, workspaceID, applicantResultClientNotificationID(pending.Request.ID)))
	require.Equal(t, int64(0), rowCount(t, ctx, db, `SELECT COUNT(*) FROM service_accounts WHERE workspace_id = $1`, workspaceID))
}

func TestApprovalAppServiceAccountActionMisbindingIsRejectedBeforeRoleSettlement(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 16, 12, 45, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_action_misbinding"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	accounts := serviceaccountflow.New(db)
	approvalStore := approval.New(db)
	subjects := newServiceAccountSubjectRegistry(t, approvalStore, accounts, projector)
	handler := NewHandler(approvalStore, notifications, projector, subjects)
	first := submitApprovalCreate(t, ctx, accounts, workspaceID, "misbinding-first", now)
	firstCards, err := projector.ProjectPendingRequest(ctx, first)
	require.NoError(t, err)
	require.Len(t, firstCards, 1)
	second, err := accounts.SubmitWorkspaceCreateRequest(ctx, serviceaccountflow.SubmitWorkspaceCreateRequest{
		WorkspaceID: workspaceID, RequesterUserID: "usr_approval_bot_reader",
		ClientRequestID: "create-misbinding-second",
		Payload:         serviceaccountflow.WorkspaceCreatePayload{Name: "Misbinding Target"},
		Now:             now.Add(time.Second),
	})
	require.NoError(t, err)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE workspace_id = $1 AND id = 'mem_approval_bot_reader'`, workspaceID)
	// Simulate a corrupted/misbound projection: its recipient basis still names
	// the first Approval assignment while the business object names the promoted
	// second request. Validation must reject before the settlement authority is
	// invoked for that second Approval.
	mustExec(t, db, `
UPDATE service_notifications
SET business_object_id = $1
WHERE workspace_id = $2 AND id = $3
`, second.Approval.ID, workspaceID, firstCards[0].NotificationID)
	authorityInstance, err := approvalStore.Load(ctx, workspaceID, second.Approval.ID)
	require.NoError(t, err)
	_, err = handler.assignmentIDForNotification(
		ctx, authorityInstance, firstCards[0].NotificationID, "mem_approval_bot_admin",
	)
	require.Error(t, err)
	persisted, err := serviceaccountstore.New(db).LoadCreateRequest(ctx, workspaceID, second.Request.ID)
	require.NoError(t, err)
	require.Equal(t, serviceaccountstore.RequestStateAwaitingAuthorization, persisted.BusinessState)
	instance, err := approval.New(db).Load(ctx, workspaceID, second.Approval.ID)
	require.NoError(t, err)
	require.Equal(t, approval.StatePending, instance.State)
}

func TestApprovalAppProjectsTeamProposalIntoApproverDM(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 6, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_team_scope"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	teamID := "team_approval_bot_scope"
	channelID := "chn_approval_bot_team_scope"
	mustExec(t, db, `
INSERT INTO workspace_teams (
 id, workspace_id, handle, display_name, description, avatar_url, visibility,
 status, channel_id,
 created_by_member_id, created_at, updated_at, archived_at, active_handle_key
) VALUES (
 $1,$2,'approval-scope','Approval Scope','Base description','','private',
 'active',$4,'mem_approval_bot_admin',$3,$3,NULL,$5
)
`, teamID, workspaceID, now, channelID, workspaceID+":approval-scope")
	mustExec(t, db, `
INSERT INTO scopes (
 id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
 archived_until_seq, status, created_by_member_id, created_at, updated_at
) VALUES ($1,$2,'channel',$3,0,0,0,'active','mem_approval_bot_admin',$4,$4)
`, channelID, workspaceID, "agent-team-channel:"+teamID, now)
	mustExec(t, db, `
INSERT INTO scope_channel_profiles (
 scope_id, workspace_id, slug, display_name, description, visibility,
 subsystem, owner_member_id, is_all_channel, created_at, updated_at
) VALUES (
 $1,$2,$3,'Approval Scope','Base description','private',
 'team','mem_approval_bot_admin',FALSE,$4,$4
)
`, channelID, workspaceID, teamID, now)
	require.NoError(t, message.EnsureScopeMembership(
		ctx, db, channelID, "mem_approval_bot_admin", "public", "", "active", 0, 0, now,
	))

	notifications := app.New(db, app.Options{})
	unreadSvc, unreadRuntime := newApprovalAppTestUnreadService(t, db)
	provisioner := NewProvisioner(db)
	provisioned, err := provisioner.EnsureWorkspace(ctx, workspaceID, now)
	require.NoError(t, err)
	dmAccess, err := message.EnsureDMThread(ctx, db, message.MemberActor{
		WorkspaceID: workspaceID,
		MemberID:    provisioned.Member.MemberID,
	}, "mem_approval_bot_admin")
	require.NoError(t, err)
	dmScopeID := message.ScopeIDForThreadAccess(dmAccess)
	require.NoError(t, unreadRuntime.WarmScopeRoster(
		workspaceID,
		dmScopeID,
		unread.ScopeTypeDM,
		[]unread.RosterMember{
			{MemberID: "mem_approval_bot_admin", UserID: "usr_approval_bot_admin", Visibility: "dm", NotificationState: "active"},
			{MemberID: provisioned.Member.MemberID, Visibility: "dm", NotificationState: "active"},
		},
	))
	notifications.SetCommittedMessageFanout(
		fanout.NewDeliveryFanout(nil, im.New(db), nil).
			WithScopeFanoutMarker(notifications).
			WithUnreadRuntime(unreadSvc),
	)
	projector := NewProjector(notifications, provisioner)
	require.NoError(t, projector.Register())
	teamHandler := &teamRoleProposalSubjectHandler{projector: projector}
	payload := []byte(`{
		"schemaVersion":4,
		"applicant":{"agentId":"agt_approval_bot_scope"},
		"base":{"team":{"description":"Base description"},"members":[]},
		"proposed":{"description":"Proposed description","members":[]}
	}`)
	decoder := json.NewDecoder(bytes.NewReader(payload))
	decoder.UseNumber()
	var payloadValue any
	require.NoError(t, decoder.Decode(&payloadValue))
	deterministicPayload, err := json.Marshal(payloadValue)
	require.NoError(t, err)
	payloadDigest := sha256.Sum256(deterministicPayload)
	instance, assignments, created, err := approval.New(db).Create(ctx, approval.CreateParams{
		ID: "appr_approval_bot_scope", WorkspaceID: workspaceID,
		SubjectType: SubjectTypeTeamRoleProposal, SubjectRef: teamID,
		SubjectVersion: 1, Payload: deterministicPayload,
		PayloadDigest:       hex.EncodeToString(payloadDigest[:]),
		IdempotencyKey:      "approval-app-team-scope",
		PendingKey:          "approval-app-team-scope-pending",
		ApplicantMemberID:   "mem_approval_bot_member",
		AllowAgentApplicant: true,
		ApproverMemberIDs:   []string{"mem_approval_bot_admin"},
		CorrelationID:       "cor_approval_bot_scope", Now: now,
	})
	require.NoError(t, err)
	require.True(t, created)
	require.Len(t, assignments, 1)
	result := roleproposal.Result{
		Proposal: roleproposal.Proposal{
			ID: instance.ID, WorkspaceID: workspaceID, TeamID: teamID,
			ApplicantMemberID: instance.ApplicantMemberID, Payload: deterministicPayload,
		},
		Approval: instance, Assignments: assignments,
	}
	projected, err := teamHandler.projectPending(
		ctx,
		result,
	)
	require.NoError(t, err)
	require.Equal(t, dmScopeID, projected.ScopeID)
	require.NotEqual(t, channelID, projected.ScopeID)
	record, err := notifications.LoadAppNotification(
		ctx, workspaceID, projected.NotificationID,
	)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationTargetHumanDM, record.TargetKind)
	require.Equal(t, "agent", record.CausedByActorType)
	require.Equal(t, "agt_approval_bot_scope", record.CausedByActorID)
	require.NotNil(t, record.RecipientMemberID)
	require.Equal(t, "mem_approval_bot_admin", *record.RecipientMemberID)
	require.NotNil(t, record.RecipientBasisKind)
	require.Equal(t, RecipientBasisAssignment, *record.RecipientBasisKind)
	require.NotNil(t, record.RecipientBasisRef)
	require.Equal(t, assignments[0].ID, *record.RecipientBasisRef)
	require.Equal(t, int64(1), unreadRuntime.ScopeUnreadCounts(
		workspaceID, "mem_approval_bot_admin", nil,
	)[projected.ScopeID])
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM scope_fanout_outbox
WHERE workspace_id = $1 AND message_id = $2 AND status = 'delivered'
`, workspaceID, projected.MessageID))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM im_stream_events
WHERE workspace_id = $1 AND scope_id = $2 AND member_id = $3 AND event_id = $4
`, workspaceID, projected.ScopeID, "mem_approval_bot_admin", "message.created:"+projected.MessageID))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND target_kind = 'human_dm'
  AND business_object_id = 'appr_approval_bot_scope'
`, workspaceID))
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND target_kind = 'scope'
  AND business_object_id = 'appr_approval_bot_scope'
`, workspaceID))

	replayed, err := teamHandler.projectPending(
		ctx,
		result,
	)
	require.NoError(t, err)
	require.True(t, replayed.AlreadyCreated)
	require.Equal(t, projected.NotificationID, replayed.NotificationID)
	require.Equal(t, int64(1), unreadSvc.ScopeUnreadCounts(
		workspaceID, "mem_approval_bot_admin", nil,
	)[projected.ScopeID])
}

type teamProposalHandlerNotifications struct {
	NotificationStore
	messages          *app.Store
	notification      app.AppNotificationRecord
	mutated           app.AppNotificationMutationCommand
	messageDeliveries []message.MessageDelivery
	messageOptions    []app.SendAppMessageOptions
	messageFailures   int
	messageCalls      int
	mutationCalls     int
}

type agentManagementHandlerNotifications struct {
	NotificationStore
	notification     app.AppNotificationRecord
	card             app.AppNotificationCardRecord
	mutated          app.AppNotificationMutationCommand
	mutationFailures int
	mutationCalls    int
	createCalls      int
}

func (store *agentManagementHandlerNotifications) CreateAppNotificationForBuiltin(
	context.Context, string, string, app.AppNotificationCommand,
) (app.AppNotificationResult, error) {
	store.createCalls++
	return app.AppNotificationResult{}, errors.New(
		"unexpected agent management notification creation",
	)
}

func (store *agentManagementHandlerNotifications) FindAppNotificationByClientID(
	_ context.Context, _, _, clientNotificationID string,
) (app.AppNotificationRecord, bool, error) {
	return store.notification,
		clientNotificationID == store.notification.ClientNotificationID, nil
}

func (store *agentManagementHandlerNotifications) LoadAppNotification(
	_ context.Context, _, _ string,
) (app.AppNotificationRecord, error) {
	return store.notification, nil
}

func (store *agentManagementHandlerNotifications) LoadAppNotificationCardInTx(
	_ context.Context, _ coredb.QueryRower, _ string, _ bool,
) (app.AppNotificationCardRecord, error) {
	return store.card, nil
}

func (store *agentManagementHandlerNotifications) MutateAppNotificationForBuiltinDelivery(
	_ context.Context, _, _ string, _, _ string,
	command app.AppNotificationMutationCommand,
) (app.AppNotificationResult, error) {
	store.mutationCalls++
	store.mutated = command
	if store.mutationFailures > 0 {
		store.mutationFailures--
		return app.AppNotificationResult{}, errors.New(
			"injected agent management projection failure",
		)
	}
	store.notification.Revision++
	if command.BusinessObjectVersion != nil {
		version := *command.BusinessObjectVersion
		store.notification.BusinessObjectVersion = &version
	}
	if command.Kind == app.AppNotificationMutationResolve {
		store.notification.Status = app.AppNotificationStatusResolved
		store.card.State = app.AppNotificationCardStateResolved
	}
	if command.Content != nil {
		store.card.RenderPayloadJSON = append(
			json.RawMessage(nil), command.Content.CardJSON...,
		)
	}
	return app.AppNotificationResult{
		NotificationID: command.NotificationID,
		MessageID:      store.notification.MessageID,
		Status:         store.notification.Status,
	}, nil
}

func (store *agentManagementHandlerNotifications) MutateAppNotificationForBuiltin(
	ctx context.Context, identityID, workspaceID string,
	command app.AppNotificationMutationCommand,
) (app.AppNotificationResult, error) {
	return store.MutateAppNotificationForBuiltinDelivery(
		ctx, identityID, workspaceID, "", "", command,
	)
}

type agentManagementHandlerConsumer struct {
	result agentapproval.DecisionResult
}

func (*agentManagementHandlerConsumer) Submit(
	context.Context,
	agent.AuthenticatedAgent,
	agentapproval.SubmitInput,
) (agentapproval.Result, error) {
	return agentapproval.Result{}, nil
}

func (consumer *agentManagementHandlerConsumer) Decide(
	context.Context,
	approval.DecideParams,
) (agentapproval.DecisionResult, error) {
	return consumer.result, nil
}

type recordingAgentManagementConsumer struct {
	service *agentapproval.Service
	results []agentapproval.DecisionResult
}

func (consumer *recordingAgentManagementConsumer) Submit(
	ctx context.Context,
	principal agent.AuthenticatedAgent,
	input agentapproval.SubmitInput,
) (agentapproval.Result, error) {
	return consumer.service.Submit(ctx, principal, input)
}

func (consumer *recordingAgentManagementConsumer) Decide(
	ctx context.Context,
	params approval.DecideParams,
) (agentapproval.DecisionResult, error) {
	result, err := consumer.service.Decide(ctx, params)
	if err == nil {
		consumer.results = append(consumer.results, result)
	}
	return result, err
}

func TestApprovalAppHandlerReturnsMessageUpdateForAgentManagementProcessing(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 8, 2, 8, 0, 0, 0, time.UTC)
	workspaceID := "wsp_agent_management_handler"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	payload, err := json.Marshal(agentapproval.Payload{
		SchemaVersion: agentapproval.PayloadSchemaVersion,
		Action:        agentapproval.ActionCreate,
		Applicant: agentapproval.Applicant{
			AgentID:  "agt_agent_management_handler",
			MemberID: "mem_approval_bot_member",
		},
		Owner: agentapproval.OwnerRef{
			UserID: "usr_approval_bot_admin", MemberID: "mem_approval_bot_admin",
		},
		Create: &agentapproval.CreateProposal{
			Name: "managed-agent", MachineID: "mac_agent_management_handler",
			Runtime: "codex",
		},
	})
	require.NoError(t, err)
	decoder := json.NewDecoder(bytes.NewReader(payload))
	decoder.UseNumber()
	var payloadValue any
	require.NoError(t, decoder.Decode(&payloadValue))
	deterministicPayload, err := json.Marshal(payloadValue)
	require.NoError(t, err)
	payloadDigest := sha256.Sum256(deterministicPayload)
	instance, assignments, created, err := approval.New(db).Create(
		ctx, approval.CreateParams{
			ID: "appr_agent_management_handler", WorkspaceID: workspaceID,
			SubjectType: agentapproval.SubjectType,
			SubjectRef:  "agent-management-handler", SubjectVersion: 1,
			Payload:           deterministicPayload,
			PayloadDigest:     hex.EncodeToString(payloadDigest[:]),
			ApplicantMemberID: "mem_approval_bot_member",
			ApproverMemberIDs: []string{"mem_approval_bot_admin"},
			CorrelationID:     "cor_agent_management_handler", Now: now,
		},
	)
	require.NoError(t, err)
	require.True(t, created)
	require.Len(t, assignments, 1)

	decided := instance
	decided.State = approval.StateApproved
	decided.Decision = approval.DecisionApproved
	decided.DecidedByMemberID = "mem_approval_bot_admin"
	decided.Version = 2
	mustExec(t, db, `
UPDATE approval_instances
SET state='approved', decision='approved',
    decided_by_member_id='mem_approval_bot_admin',
    version=2, updated_at=$3, decided_at=$3
WHERE workspace_id=$1 AND id=$2
`, workspaceID, instance.ID, now.Add(time.Second))
	mustExec(t, db, `
UPDATE approval_assignments
SET status='approved', version=2, updated_at=$3, closed_at=$3
WHERE workspace_id=$1 AND id=$2
`, workspaceID, assignments[0].ID, now.Add(time.Second))
	namespace, objectType, objectID := BusinessNamespace, BusinessTypeInstance, instance.ID
	businessVersion := instance.Version
	recipientBasisKind := RecipientBasisAssignment
	recipientBasisRef := assignments[0].ID
	recipientMemberID := "mem_approval_bot_admin"
	notificationID := "btn_agent_management_handler"
	messageID := "msg_agent_management_handler"
	notifications := &agentManagementHandlerNotifications{
		notification: app.AppNotificationRecord{
			ID: notificationID, WorkspaceID: workspaceID,
			ServiceInstallationID: "ignored-by-fake",
			TargetKind:            app.AppNotificationTargetHumanDM,
			RecipientMemberID:     &recipientMemberID,
			RecipientBasisKind:    &recipientBasisKind,
			RecipientBasisRef:     &recipientBasisRef,
			MessageID:             messageID,
			ContentKind:           app.AppNotificationContentInteractiveCard,
			ClientNotificationID: agentManagementReviewClientNotificationID(
				instance.ID,
			),
			BusinessObjectNamespace: &namespace,
			BusinessObjectType:      &objectType,
			BusinessObjectID:        &objectID,
			BusinessObjectVersion:   &businessVersion,
			Status:                  app.AppNotificationStatusActive,
			Revision:                1,
		},
		card: app.AppNotificationCardRecord{
			MessageID: messageID, AppNotificationID: notificationID,
			State: app.AppNotificationCardStateActive,
			RenderPayloadJSON: json.RawMessage(fmt.Sprintf(
				`{"schemaVersion":"1","template":"approval_action","status":"pending","approval":{"id":%q,"version":1,"assignmentId":%q},"actions":[{"actionKey":"approve"}]}`,
				instance.ID, assignments[0].ID,
			)),
		},
	}
	projector := NewProjector(notifications, NewProvisioner(db))
	consumer := &agentManagementHandlerConsumer{
		result: agentapproval.DecisionResult{
			Instance: decided, OperationID: 42,
		},
	}
	marks := map[string]int64{}
	subjects := NewSubjectRegistry(subjectRegistryTestLoader{
		instance: instance, marks: marks,
	})
	require.NoError(t, subjects.Register(
		NewAgentManagementSubjectHandler(approval.New(db), consumer, projector),
	))
	subjects.Seal()
	handler := NewHandler(approval.New(db), nil, projector, subjects)
	eventID := "bna_agent_management_handler"
	eventPayload, err := json.Marshal(map[string]any{
		"eventType": "agentteam.app.notification.action",
		"notification": map[string]any{
			"id": notificationID,
			"businessObject": map[string]any{
				"namespace": BusinessNamespace, "type": BusinessTypeInstance,
				"id": instance.ID, "version": instance.Version,
			},
		},
		"action": map[string]any{
			"invocationId": eventID, "actionKey": ActionApprove,
			"input": map[string]any{},
		},
		"actor": map[string]any{
			"memberId": "mem_approval_bot_admin",
			"userId":   "usr_approval_bot_admin",
		},
		"correlationId": "cor_agent_management_handler",
	})
	require.NoError(t, err)
	outcome, err := handler.HandleInternalDelivery(
		ctx, appruntime.InternalHandlerEnvelope{
			HandlerKey: HandlerKey,
			DeliveryEnvelope: serviceactor.DeliveryEnvelope{
				Delivery: serviceactor.EventDelivery{
					ID: "sed_agent_management_handler", WorkspaceID: workspaceID,
					EventID: eventID, ExternalResourceKey: notificationID,
				},
				Attempt: serviceactor.DeliveryAttempt{
					ID: "sda_agent_management_handler",
				},
				EventPayload: eventPayload,
			},
		},
	)
	require.NoError(t, err)
	require.Equal(t,
		runeffect.MessageUpdateEffect(notificationID, messageID),
		outcome.Effect,
	)
	require.Equal(t, app.AppNotificationMutationUpdate, notifications.mutated.Kind)
	require.Equal(t, instance.Version+1, marks[instance.ID])
	require.Contains(t, string(outcome.ResultJSON), `"operationId":42`)
}

func TestApprovalAppHandlerRetriesAgentManagementProjectionWithStableOperation(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)
	workspaceID := "wsp_agent_management_retry"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	ownerUserID := "usr_agent_management_retry_owner"
	ownerMemberID := "mem_agent_management_retry_owner"
	applicantAgentID := "agt_agent_management_retry_app"
	applicantMemberID := "mem_agent_management_retry_app"
	machineID := "mac_agent_management_retry"
	mustExec(t, db, `INSERT INTO users (id, email, name, display_name, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$3,'hash',$4,$4)`, ownerUserID, ownerUserID+"@example.com", ownerUserID, now)
	mustExec(t, db, `INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at) VALUES ($1,$2,'human',$1,$3,'owner','active',$4,$4),($5,$2,'agent',$5,NULL,NULL,'active',$4,$4)`, ownerMemberID, workspaceID, ownerUserID, now, applicantMemberID)
	mustExec(t, db, `INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at) VALUES ($1,$2,$3,$1,$4,$5,$5)`, machineID, workspaceID, ownerUserID, "key-"+machineID, now)
	mustExec(t, db, `INSERT INTO agents (id, workspace_id, machine_id, member_id, name, avatar_url, runtime, model, description, auth_token_hash, activity_detail, created_at, updated_at) VALUES ($1,$2,$3,$4,$1,'','codex','gpt-5','',NULL,'',$5,$5)`, applicantAgentID, workspaceID, machineID, applicantMemberID, now)

	approvalStore := approval.New(db)
	domainService := agentapproval.New(
		db, approvalStore, agentops.New(db, agentops.Options{}),
	)
	submitted, err := domainService.Submit(
		ctx,
		agent.AuthenticatedAgent{
			AgentID: applicantAgentID, WorkspaceID: workspaceID,
			MemberID: applicantMemberID,
		},
		agentapproval.SubmitInput{
			ClientRequestID: "req_agent_management_retry",
			CorrelationID:   "cor_agent_management_retry",
			Now:             now,
			Payload: agentapproval.Payload{
				Action: agentapproval.ActionCreate,
				Create: &agentapproval.CreateProposal{
					Name: "managed-retry", MachineID: machineID,
					Runtime: "codex",
				},
			},
		},
	)
	require.NoError(t, err)
	require.Len(t, submitted.Assignments, 1)

	namespace, objectType, objectID := BusinessNamespace, BusinessTypeInstance,
		submitted.Approval.ID
	businessVersion := submitted.Approval.Version
	recipientBasisKind := RecipientBasisAssignment
	recipientBasisRef := submitted.Assignments[0].ID
	notificationID := "btn_agent_management_retry"
	messageID := "msg_agent_management_retry"
	notifications := &agentManagementHandlerNotifications{
		notification: app.AppNotificationRecord{
			ID: notificationID, WorkspaceID: workspaceID,
			TargetKind:         app.AppNotificationTargetHumanDM,
			RecipientMemberID:  &ownerMemberID,
			RecipientBasisKind: &recipientBasisKind,
			RecipientBasisRef:  &recipientBasisRef,
			MessageID:          messageID,
			ContentKind:        app.AppNotificationContentInteractiveCard,
			ClientNotificationID: agentManagementReviewClientNotificationID(
				submitted.Approval.ID,
			),
			BusinessObjectNamespace: &namespace,
			BusinessObjectType:      &objectType,
			BusinessObjectID:        &objectID,
			BusinessObjectVersion:   &businessVersion,
			Status:                  app.AppNotificationStatusActive,
			Revision:                1,
		},
		card: app.AppNotificationCardRecord{
			MessageID: messageID, AppNotificationID: notificationID,
			State: app.AppNotificationCardStateActive,
			RenderPayloadJSON: json.RawMessage(fmt.Sprintf(
				`{"schemaVersion":"1","template":"approval_action","status":"pending","approval":{"id":%q,"version":1,"assignmentId":%q},"actions":[{"actionKey":"approve"}]}`,
				submitted.Approval.ID, submitted.Assignments[0].ID,
			)),
		},
		mutationFailures: 1,
	}
	projector := NewProjector(notifications, NewProvisioner(db))
	consumer := &recordingAgentManagementConsumer{service: domainService}
	subjects := NewSubjectRegistry(approvalStore)
	require.NoError(t, subjects.Register(
		NewAgentManagementSubjectHandler(approvalStore, consumer, projector),
	))
	subjects.Seal()
	handler := NewHandler(approvalStore, nil, projector, subjects)
	eventID := "bna_agent_management_retry"
	eventPayload, err := json.Marshal(map[string]any{
		"eventType": "agentteam.app.notification.action",
		"notification": map[string]any{
			"id": notificationID,
			"businessObject": map[string]any{
				"namespace": BusinessNamespace, "type": BusinessTypeInstance,
				"id":      submitted.Approval.ID,
				"version": submitted.Approval.Version,
			},
		},
		"action": map[string]any{
			"invocationId": eventID, "actionKey": ActionApprove,
			"input": map[string]any{},
		},
		"actor": map[string]any{
			"memberId": ownerMemberID, "userId": ownerUserID,
		},
		"correlationId": "cor_agent_management_retry",
	})
	require.NoError(t, err)
	envelope := appruntime.InternalHandlerEnvelope{
		HandlerKey: HandlerKey,
		DeliveryEnvelope: serviceactor.DeliveryEnvelope{
			Delivery: serviceactor.EventDelivery{
				ID: "sed_agent_management_retry", WorkspaceID: workspaceID,
				EventID: eventID, ExternalResourceKey: notificationID,
			},
			Attempt: serviceactor.DeliveryAttempt{
				ID: "sda_agent_management_retry",
			},
			EventPayload: eventPayload,
		},
	}

	_, err = handler.HandleInternalDelivery(ctx, envelope)
	require.ErrorContains(t, err, "injected agent management projection failure")
	require.Len(t, consumer.results, 1)
	require.NotZero(t, consumer.results[0].OperationID)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM agent_operations
WHERE workspace_id = $1 AND approval_id = $2
`, workspaceID, submitted.Approval.ID))
	require.Equal(t, app.AppNotificationStatusActive, notifications.notification.Status)
	require.Equal(t, int64(1), notifications.notification.Revision)

	outcome, err := handler.HandleInternalDelivery(ctx, envelope)
	require.NoError(t, err)
	require.Len(t, consumer.results, 2)
	require.True(t, consumer.results[1].Replay)
	require.Equal(
		t, consumer.results[0].OperationID, consumer.results[1].OperationID,
	)
	require.NotZero(t, consumer.results[1].OperationID)
	var result struct {
		OperationID int64 `json:"operationId"`
	}
	require.NoError(t, json.Unmarshal(outcome.ResultJSON, &result))
	require.Equal(t, consumer.results[0].OperationID, result.OperationID)
	require.Equal(t,
		runeffect.MessageUpdateEffect(notificationID, messageID),
		outcome.Effect,
	)
	require.Equal(t, 2, notifications.mutationCalls)
	require.Zero(t, notifications.createCalls)
	require.Equal(t, notificationID, notifications.notification.ID)
	require.Equal(t, messageID, notifications.notification.MessageID)
	require.Equal(t, app.AppNotificationStatusActive, notifications.notification.Status)
	require.Equal(t, int64(2), notifications.notification.Revision)
	var card struct {
		Status  string `json:"status"`
		Actions []any  `json:"actions"`
	}
	require.NoError(t, json.Unmarshal(
		notifications.card.RenderPayloadJSON, &card,
	))
	require.Equal(t, agentManagementCardStatusProcessing, card.Status)
	require.Empty(t, card.Actions)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM agent_operations
WHERE workspace_id = $1 AND approval_id = $2
`, workspaceID, submitted.Approval.ID))
}

func (store *teamProposalHandlerNotifications) SendAppMessageForUserWithOptions(
	ctx context.Context,
	user model.User,
	workspaceID string,
	channelID string,
	content string,
	attachmentIDs []string,
	options app.SendAppMessageOptions,
) (message.AppMessage, message.MessageDelivery, error) {
	store.messageCalls++
	store.messageOptions = append(store.messageOptions, options)
	if store.messageFailures > 0 {
		store.messageFailures--
		return message.AppMessage{}, message.MessageDelivery{}, errors.New("injected Team role feedback message failure")
	}
	created, delivery, err := store.messages.SendAppMessageForUserWithOptions(
		ctx, user, workspaceID, channelID, content, attachmentIDs, options,
	)
	if err == nil {
		store.messageDeliveries = append(store.messageDeliveries, delivery)
	}
	return created, delivery, err
}

func (store *teamProposalHandlerNotifications) LoadAppNotification(
	_ context.Context, _, _ string,
) (app.AppNotificationRecord, error) {
	return store.notification, nil
}

func (store *teamProposalHandlerNotifications) MutateAppNotificationForBuiltinDelivery(
	_ context.Context, _, _ string, _, _ string,
	command app.AppNotificationMutationCommand,
) (app.AppNotificationResult, error) {
	store.mutationCalls++
	store.mutated = command
	return app.AppNotificationResult{
		NotificationID:       command.NotificationID,
		NotificationRevision: command.ExpectedRevision + 1,
		MessageID:            "msg_team_handler",
	}, nil
}

type teamProposalHandlerConsumer struct {
	params approval.DecideParams
	result roleproposal.DecisionResult
}

func (consumer *teamProposalHandlerConsumer) Submit(_ context.Context, _ agent.AuthenticatedAgent, _ roleproposal.SubmitInput) (roleproposal.Result, error) {
	return roleproposal.Result{}, nil
}
func (consumer *teamProposalHandlerConsumer) Withdraw(_ context.Context, _ agent.AuthenticatedAgent, _ roleproposal.WithdrawInput) (roleproposal.WithdrawResult, error) {
	return roleproposal.WithdrawResult{}, nil
}

func (consumer *teamProposalHandlerConsumer) Decide(
	_ context.Context, params approval.DecideParams,
) (roleproposal.DecisionResult, error) {
	consumer.params = params
	return consumer.result, nil
}

func (consumer *teamProposalHandlerConsumer) Load(
	_ context.Context, _, _ string,
) (roleproposal.Proposal, error) {
	return consumer.result.Proposal, nil
}

func TestApprovalAppHandlerDispatchesTeamProposalWithoutServiceAccountConsumer(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 7, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_team_handler"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	payloadSnapshot := []byte(`{"schemaVersion":4,"applicant":{"agentId":"agt_team_handler"},"base":{"team":{"description":""},"members":[]},"proposed":{"description":"new","members":[]}}`)
	decoder := json.NewDecoder(bytes.NewReader(payloadSnapshot))
	decoder.UseNumber()
	var payloadValue any
	require.NoError(t, decoder.Decode(&payloadValue))
	deterministicPayload, err := json.Marshal(payloadValue)
	require.NoError(t, err)
	payloadDigestBytes := sha256.Sum256(deterministicPayload)
	instance, assignments, created, err := approval.New(db).Create(ctx, approval.CreateParams{
		ID: "appr_team_handler", WorkspaceID: workspaceID,
		SubjectType: SubjectTypeTeamRoleProposal, SubjectRef: "team_team_handler",
		SubjectVersion: 1, Payload: payloadSnapshot,
		PayloadDigest:     hex.EncodeToString(payloadDigestBytes[:]),
		ApplicantMemberID: "mem_approval_bot_member",
		ApproverMemberIDs: []string{"mem_approval_bot_admin"},
		CorrelationID:     "cor_team_handler", Now: now,
	})
	require.NoError(t, err)
	require.True(t, created)
	require.Len(t, assignments, 1)

	businessNamespace := BusinessNamespace
	businessType := BusinessTypeInstance
	businessID := instance.ID
	recipientMemberID := assignments[0].ApproverMemberID
	recipientBasisKind := RecipientBasisAssignment
	recipientBasisRef := assignments[0].ID
	notifications := &teamProposalHandlerNotifications{
		notification: app.AppNotificationRecord{
			ID: "btn_team_handler", WorkspaceID: workspaceID,
			TargetKind:              app.AppNotificationTargetHumanDM,
			RecipientMemberID:       &recipientMemberID,
			RecipientBasisKind:      &recipientBasisKind,
			RecipientBasisRef:       &recipientBasisRef,
			BusinessObjectNamespace: &businessNamespace,
			BusinessObjectType:      &businessType,
			BusinessObjectID:        &businessID, Revision: 1,
		},
	}
	decided := instance
	decided.State = approval.StateApproved
	decided.Decision = approval.DecisionApproved
	decided.DecidedByMemberID = "mem_approval_bot_admin"
	decided.Version = 2
	consumer := &teamProposalHandlerConsumer{
		result: roleproposal.DecisionResult{
			Proposal: roleproposal.Proposal{
				ID: "appr_team_handler", WorkspaceID: workspaceID,
				TeamID: "team_team_handler", Status: roleproposal.StatusApplied,
				Payload: []byte(`{"schemaVersion":4,"applicant":{"agentId":"agt_team_handler"},"proposed":{"description":"new","members":[]}}`),
			},
			Approval: approval.DecisionResult{Instance: decided},
			Changed:  true, Applied: true,
		},
	}
	projector := NewProjector(notifications, NewProvisioner(db))
	approvalStore := approval.New(db)
	subjects := NewSubjectRegistry(subjectRegistryTestLoader{
		instance: instance, marks: map[string]int64{},
	})
	require.NoError(t, subjects.Register(NewTeamRoleProposalSubjectHandler(approvalStore, consumer, projector)))
	subjects.Seal()
	handler := NewHandler(approvalStore, nil, projector, subjects)
	eventID := "bna_team_handler"
	eventPayload, err := json.Marshal(map[string]any{
		"eventType": "agentteam.app.notification.action",
		"notification": map[string]any{
			"id": "btn_team_handler",
			"businessObject": map[string]any{
				"namespace": BusinessNamespace, "type": BusinessTypeInstance,
				"id": instance.ID, "version": instance.Version,
			},
		},
		"action": map[string]any{
			"invocationId": eventID, "actionKey": ActionApprove,
			"input": map[string]any{},
		},
		"actor": map[string]any{
			"memberId": "mem_approval_bot_admin",
			"userId":   "usr_approval_bot_admin",
		},
		"correlationId": "cor_team_handler",
	})
	require.NoError(t, err)
	outcome, err := handler.HandleInternalDelivery(ctx, appruntime.InternalHandlerEnvelope{
		HandlerKey: HandlerKey,
		DeliveryEnvelope: serviceactor.DeliveryEnvelope{
			Delivery: serviceactor.EventDelivery{
				ID: "sed_team_handler", WorkspaceID: workspaceID,
				EventID: eventID, ExternalResourceKey: "btn_team_handler",
			},
			Attempt:      serviceactor.DeliveryAttempt{ID: "sda_team_handler"},
			EventPayload: eventPayload,
		},
	})
	require.NoError(t, err)
	require.Equal(t, instance.ID, consumer.params.ApprovalID)
	require.Equal(t, assignments[0].ID, consumer.params.AssignmentID)
	require.Equal(t, approval.DecisionApproved, consumer.params.Decision)
	require.Equal(t, eventID, consumer.params.ClientDecisionID)
	require.Equal(t, app.AppNotificationMutationResolve, notifications.mutated.Kind)
	require.Equal(t, "btn_team_handler", notifications.mutated.NotificationID)
	require.Equal(t, "human", notifications.mutated.CausedByActor.Type)
	require.Equal(t, "mem_approval_bot_admin", notifications.mutated.CausedByActor.ID)
	require.Equal(t,
		runeffect.NotificationResolvedEffect(
			"btn_team_handler", "msg_team_handler",
		),
		outcome.Effect,
	)
	require.NotEmpty(t, outcome.ResultJSON)
}

func TestApprovalAppHandlerReturnsRejectedTeamProposalToCurrentLeaderOnce(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 27, 9, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_team_rejection"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	const teamID = "team_approval_bot_rejection"
	messages := app.New(db, app.Options{})
	machine, _, err := messages.CreateMachineForWorkspace(
		ctx, "usr_approval_bot_admin", workspaceID, "team rejection machine",
	)
	require.NoError(t, err)
	createAgent := func(name string) agent.AgentView {
		agent, createErr := messages.CreateAgentForUser(
			ctx, "usr_approval_bot_admin", workspaceID, machine.ID,
			agent.CreateAgentParams{Name: name, DisplayName: name, Runtime: "codex", Model: "gpt-5.4"},
		)
		require.NoError(t, createErr)
		return agent
	}
	historicalLeader := createAgent("historical-leader")
	currentLeader := createAgent("current-leader")

	teamStore := team.New(db)
	currentTeam, err := teamStore.CreateTeam(ctx, team.CreateTeamParams{
		ID:          teamID,
		WorkspaceID: workspaceID,
		Handle:      "approval-rejection",
		DisplayName: "Approval Rejection",
		Description: "Coordinate the current Team.",
		InitialMembers: []team.AddTeamMemberParams{
			{
				ID:                "wtm_team_rejection_historical",
				ActorKind:         team.ActorKindAgent,
				ActorID:           historicalLeader.MemberID,
				Role:              team.MemberRoleLeader,
				RoleOverlayPrompt: "Coordinate the original Team.",
			},
			{
				ID:                "wtm_team_rejection_current_leader",
				ActorKind:         team.ActorKindAgent,
				ActorID:           currentLeader.MemberID,
				Role:              team.MemberRoleMember,
				RoleOverlayPrompt: "Implement and review assigned work.",
			},
		},
		CreatedByMemberID: "mem_approval_bot_admin",
		Now:               now.Add(time.Minute),
	})
	require.NoError(t, err)
	require.NotEmpty(t, currentTeam.ChannelID)

	proposalService := roleproposal.New(db)
	submitted, err := proposalService.Submit(ctx, agent.AuthenticatedAgent{
		AgentID: historicalLeader.AgentID, WorkspaceID: workspaceID, MemberID: historicalLeader.MemberID,
	}, roleproposal.SubmitInput{
		TeamID:          teamID,
		ClientRequestID: "team-rejection-original",
		Description:     approvalAppString("Rejected description"),
		Members: []roleproposal.MemberInput{
			{TeamMemberID: "wtm_team_rejection_historical", RoleOverlayPrompt: approvalAppString("Rejected historical role.")},
			{TeamMemberID: "wtm_team_rejection_current_leader", RoleOverlayPrompt: approvalAppString("Rejected current leader role.")},
		},
		CorrelationID: "cor_team_rejection",
		Now:           now.Add(2 * time.Minute),
	})
	require.NoError(t, err)
	leaderRole := team.MemberRoleLeader
	_, err = teamStore.UpdateTeamMember(ctx, workspaceID, teamID, "wtm_team_rejection_current_leader", team.UpdateTeamMemberParams{
		Role: &leaderRole,
		Now:  now.Add(3 * time.Minute),
	})
	require.NoError(t, err)
	instance := submitted.Approval

	businessNamespace := BusinessNamespace
	businessType := BusinessTypeInstance
	businessID := instance.ID
	recipientMemberID := submitted.Assignments[0].ApproverMemberID
	recipientBasisKind := RecipientBasisAssignment
	recipientBasisRef := submitted.Assignments[0].ID
	notifications := &teamProposalHandlerNotifications{
		messages:        messages,
		messageFailures: 1,
		notification: app.AppNotificationRecord{
			ID: "btn_team_rejection", WorkspaceID: workspaceID,
			TargetKind:              app.AppNotificationTargetHumanDM,
			RecipientMemberID:       &recipientMemberID,
			RecipientBasisKind:      &recipientBasisKind,
			RecipientBasisRef:       &recipientBasisRef,
			BusinessObjectNamespace: &businessNamespace,
			BusinessObjectType:      &businessType,
			BusinessObjectID:        &businessID, Revision: 1,
		},
	}
	const rejectionReason = "Keep the former leader focused on review.\nDo not ask @historical-leader to lead."
	projector := NewProjector(notifications, NewProvisioner(db))
	approvalStore := approval.New(db)
	subjects := NewSubjectRegistry(approvalStore)
	require.NoError(t, subjects.Register(NewTeamRoleProposalSubjectHandler(approvalStore, proposalService, projector)))
	subjects.Seal()
	handler := NewHandler(approvalStore, nil, projector, subjects)
	eventID := "bna_team_rejection"
	eventPayload, err := json.Marshal(map[string]any{
		"eventType": "agentteam.app.notification.action",
		"notification": map[string]any{
			"id": "btn_team_rejection",
			"businessObject": map[string]any{
				"namespace": BusinessNamespace, "type": BusinessTypeInstance,
				"id": instance.ID, "version": instance.Version,
			},
		},
		"action": map[string]any{
			"invocationId": eventID, "actionKey": ActionReject,
			"input": map[string]any{"reason": rejectionReason, "locale": "en"},
		},
		"actor": map[string]any{
			"memberId": "mem_approval_bot_admin",
			"userId":   "usr_approval_bot_admin",
		},
		"correlationId": "cor_team_rejection",
	})
	require.NoError(t, err)
	envelope := appruntime.InternalHandlerEnvelope{
		HandlerKey: HandlerKey,
		DeliveryEnvelope: serviceactor.DeliveryEnvelope{
			Delivery: serviceactor.EventDelivery{
				ID: "sed_team_rejection", WorkspaceID: workspaceID,
				EventID: eventID, ExternalResourceKey: "btn_team_rejection",
			},
			Attempt:      serviceactor.DeliveryAttempt{ID: "sda_team_rejection"},
			EventPayload: eventPayload,
		},
	}

	_, err = handler.HandleInternalDelivery(ctx, envelope)
	require.ErrorContains(t, err, "injected Team role feedback message failure")
	require.Equal(t, 1, notifications.mutationCalls)
	require.Equal(t, 1, notifications.messageCalls)
	require.Empty(t, notifications.messageDeliveries)
	require.Len(t, notifications.messageOptions, 1)
	require.True(t, notifications.messageOptions[0].SkipContentModeration)
	require.NotNil(t, notifications.mutated.Content)
	require.Contains(t, string(notifications.mutated.Content.CardJSON), `"state":"rejected"`)

	decided, err := approvalStore.Load(ctx, workspaceID, instance.ID)
	require.NoError(t, err)
	require.Equal(t, approval.StateRejected, decided.State)
	feedbackClientMessageID := teamRoleRejectionFeedbackClientMessageID(decided)
	require.Equal(t, feedbackClientMessageID, notifications.messageOptions[0].ClientMessageID)
	_, err = teamStore.UpdateTeamMember(ctx, workspaceID, teamID, "wtm_team_rejection_historical", team.UpdateTeamMemberParams{
		Role: &leaderRole,
		Now:  now.Add(4 * time.Minute),
	})
	require.NoError(t, err)
	expectedFeedbackContent := teamRoleRejectionFeedbackContent(
		"[@Leader](/mentions/agent/"+historicalLeader.AgentID+")", rejectionReason, "en",
	)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM scope_messages
WHERE workspace_id = $1 AND scope_id = $2 AND client_message_id = $3
`, workspaceID, currentTeam.ChannelID, feedbackClientMessageID))

	outcome, err := handler.HandleInternalDelivery(ctx, envelope)
	require.NoError(t, err)
	require.NotEmpty(t, outcome.ResultJSON)
	require.Equal(t, 2, notifications.mutationCalls)
	require.Equal(t, 2, notifications.messageCalls)
	require.Len(t, notifications.messageDeliveries, 1)
	require.False(t, notifications.messageDeliveries[0].IdempotentReplay)
	require.Equal(t, feedbackClientMessageID, notifications.messageOptions[1].ClientMessageID)

	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM scope_messages
WHERE workspace_id = $1 AND scope_id = $2 AND client_message_id = $3
`, workspaceID, currentTeam.ChannelID, feedbackClientMessageID))
	var feedbackMessageID string
	var feedbackContent string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id, content
FROM scope_messages
WHERE workspace_id = $1 AND scope_id = $2 AND client_message_id = $3
	`, workspaceID, currentTeam.ChannelID, feedbackClientMessageID).Scan(
		&feedbackMessageID, &feedbackContent,
	))
	require.Equal(t, expectedFeedbackContent, feedbackContent)

	var rawFanout []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT payload
FROM scope_fanout_outbox
WHERE workspace_id = $1 AND scope_id = $2 AND message_id = $3
`, workspaceID, currentTeam.ChannelID, feedbackMessageID).Scan(&rawFanout))
	fanout, err := message.UnmarshalScopeFanoutPayload(rawFanout)
	require.NoError(t, err)
	require.Len(t, fanout.Delivery.WakeRecipients, 1)
	require.Equal(t, historicalLeader.MemberID, fanout.Delivery.WakeRecipients[0].AgentMemberID)
}

func TestApprovalAppAppInstallationApproveRejectReplayAndHistory(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_app_e2e"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE id = 'mem_approval_bot_reader'`)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	conversations := message.New(db, nil, 0, message.Options{})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	realtime := &recordingWorkspaceAppRealtime{}
	handler := NewHandler(approval.New(db), notifications, projector, sealedEmptySubjectRegistry(), realtime)
	registry := appruntime.NewInternalHandlerRegistry()
	require.NoError(t, registry.Register(HandlerKey, handler))
	registry.Seal()
	dispatcher := appruntime.NewInternalDispatcher(appruntime.InternalDispatcherOptions{
		Store: notifications, Registry: registry, InstanceID: "approval-app-app-e2e", ClaimLimit: 10,
	})

	request := requestGiteaInstallationForApprovalAppTest(
		t, ctx, notifications, "usr_approval_bot_member", workspaceID, "project automation",
	)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 2)
	replayedCards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, replayedCards, 2)
	for index := range replayedCards {
		require.True(t, replayedCards[index].AlreadyCreated)
		require.Equal(t, cards[index].NotificationID, replayedCards[index].NotificationID)
	}
	for _, card := range cards {
		pendingReviewer, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.NotNil(t, pendingReviewer.BusinessObjectURL)
		require.Equal(t, "/apps/gitea", *pendingReviewer.BusinessObjectURL)
	}
	pendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(request.ID),
		app.AppNotificationStatusActive, "App 安装申请已提交：Gitea",
	)
	require.Equal(t, int64(1), pendingReceipt.Revision)
	applicantRead, err := conversations.PerformConversationActionForUser(
		ctx, "usr_approval_bot_member", workspaceID, "dm:"+pendingReceipt.ScopeID,
		message.ConversationActionMarkRead,
	)
	require.NoError(t, err)
	require.Zero(t, applicantRead.UnreadCount)
	require.False(t, applicantRead.ManualUnread)
	require.Equal(t, pendingReceipt.MessageSeq, applicantRead.LastReadSeq)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisAppInstallationApplicant, request.ID))
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_TYPE(JSON_EXTRACT(action_schema_json, '$.actions')) = 'ARRAY'
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, pendingReceipt.MessageID), "Applicant pending App receipts must not expose actions")

	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id FROM service_installations
WHERE workspace_id = $1 AND service_definition_id = $2 AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))
	var policyVersion int64
	var policyConfig []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT policy_version, config
FROM service_grants
WHERE service_installation_id = $1
  AND scope = 'app.notifications.write' AND revoked_at IS NULL
`, installationID).Scan(&policyVersion, &policyConfig))
	require.Equal(t, int64(2), policyVersion)
	require.Contains(t, string(policyConfig), `"app_installation_reviewer"`)
	require.Contains(t, string(policyConfig), `"app_installation_request"`)
	reviewerRead, err := conversations.PerformConversationActionForUser(
		ctx, "usr_approval_bot_admin", workspaceID, "dm:"+cards[0].ScopeID,
		message.ConversationActionMarkRead,
	)
	require.NoError(t, err)
	require.Zero(t, reviewerRead.UnreadCount)
	require.Equal(t, cards[0].MessageSeq, reviewerRead.LastReadSeq)

	actionCommand := app.AppNotificationActionCommand{
		MessageID: cards[0].MessageID, ActionKey: ActionApprove,
		CardRevision: 1, ClientActionID: "approve-app-installation", Input: map[string]any{},
	}
	action, err := notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, actionCommand)
	require.NoError(t, err)
	dispatched, err := dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	require.Equal(t, []string{workspaceID}, realtime.workspaceIDs)
	approved, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusApproved, approved.Status)
	require.NotEmpty(t, approved.InstallationID)
	var resolved app.AppNotificationRecord
	for index, card := range cards {
		current, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppNotificationStatusResolved, current.Status)
		require.Equal(t, "mem_approval_bot_admin", current.CausedByActorID)
		require.Equal(t, card.MessageID, current.MessageID)
		if index == 0 {
			resolved = current
		}
	}
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisAppInstallationApplicant, request.ID))
	resolvedReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(request.ID),
		app.AppNotificationStatusResolved, "App 安装申请已通过：Gitea",
	)
	require.Equal(t, int64(2), resolvedReceipt.Revision)
	require.Equal(t, pendingReceipt.NotificationID, resolvedReceipt.NotificationID)
	require.Equal(t, pendingReceipt.MessageID, resolvedReceipt.MessageID)
	require.Equal(t, pendingReceipt.MessageSeq, resolvedReceipt.MessageSeq)
	requireApprovalCardTemplate(t, ctx, db, resolvedReceipt.MessageID, "")
	approvedResult := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantResultClientNotificationID(request.ID),
		app.AppNotificationStatusActive, "你的 App 安装申请已通过：Gitea",
	)
	require.Equal(t, int64(1), approvedResult.Revision)
	require.NotEqual(t, resolvedReceipt.NotificationID, approvedResult.NotificationID)
	require.NotEqual(t, resolvedReceipt.MessageID, approvedResult.MessageID)
	require.Equal(t, resolvedReceipt.ScopeID, approvedResult.ScopeID)
	require.Greater(t, approvedResult.MessageSeq, resolvedReceipt.MessageSeq)
	requireApprovalResultTemplate(t, ctx, db, approvedResult.MessageID, 1)
	approvedResultRecord, err := notifications.LoadAppNotification(ctx, workspaceID, approvedResult.NotificationID)
	require.NoError(t, err)
	require.NotNil(t, approvedResultRecord.BusinessObjectURL)
	require.Equal(t, "/apps/gitea", *approvedResultRecord.BusinessObjectURL)
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID)
	requireNaturalConversationUnread(
		t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID,
		pendingReceipt.MessageSeq, 1,
	)
	requireNaturalConversationUnread(
		t, ctx, db, "mem_approval_bot_admin", cards[0].ScopeID,
		cards[0].MessageSeq, 0,
	)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM scope_fanout_outbox WHERE message_id = $1
`, approvedResult.MessageID), "fresh applicant result must use ordinary message fan-out")
	mustExec(t, db, `UPDATE users SET display_name = 'Renamed app processor' WHERE id = 'usr_approval_bot_admin'`)
	replayedDecision, err := projector.ProjectAppInstallationRequestDecision(ctx, approved)
	require.NoError(t, err)
	require.True(t, replayedDecision.AlreadyCreated)
	require.Equal(t, approvedResult.NotificationID, replayedDecision.NotificationID)
	require.Equal(t, approvedResult.MessageID, replayedDecision.MessageID)
	replayedResultRecord, err := notifications.LoadAppNotification(ctx, workspaceID, approvedResult.NotificationID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusActive, replayedResultRecord.Status)
	requireApprovalResultTemplate(t, ctx, db, approvedResult.MessageID, 1)
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2 AND business_object_id = $3
`, workspaceID, RecipientBasisAppInstallationApplicant, request.ID))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM scope_fanout_outbox WHERE message_id = $1
`, approvedResult.MessageID))
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", approvedResult.ScopeID)

	history, err := notifications.ListMessagesForUser(ctx, "usr_approval_bot_admin", workspaceID, resolved.ScopeID, 20, nil, nil)
	require.NoError(t, err)
	require.NotEmpty(t, history.Messages)
	var historyCardFound bool
	for _, item := range history.Messages {
		if item.ID == resolved.MessageID {
			historyCardFound = true
			require.Contains(t, item.Content, "App 安装申请已通过")
		}
	}
	require.True(t, historyCardFound, "resolved Approval card must remain in ordinary DM history")

	replayedAction, err := notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, actionCommand)
	require.NoError(t, err)
	require.True(t, replayedAction.AlreadyAccepted)
	require.Equal(t, action.ActionInvocationID, replayedAction.ActionInvocationID)

	rejectedRequest := requestVerifiedGitHubInstallationForApprovalAppTest(
		t, ctx, notifications, "usr_approval_bot_member", workspaceID, "need a second integration",
	)
	rejectCards, err := projector.ProjectPendingAppInstallationRequest(ctx, rejectedRequest)
	require.NoError(t, err)
	require.Len(t, rejectCards, 2)
	rejectedPendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(rejectedRequest.ID),
		app.AppNotificationStatusActive, "App 安装申请已提交：GitHub",
	)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2 AND business_object_id = $3
`, workspaceID, RecipientBasisAppInstallationApplicant, rejectedRequest.ID))
	var reviewerActionSchemaJSON []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT action_schema_json FROM service_notification_cards WHERE message_id = $1
`, rejectCards[0].MessageID).Scan(&reviewerActionSchemaJSON))
	var reviewerActionSchema struct {
		Actions []struct {
			ActionKey   string `json:"actionKey"`
			InputSchema struct {
				Properties map[string]struct {
					Type string `json:"type"`
				} `json:"properties"`
				Required []string `json:"required"`
			} `json:"inputSchema"`
		} `json:"actions"`
	}
	require.NoError(t, json.Unmarshal(reviewerActionSchemaJSON, &reviewerActionSchema))
	var rejectSchema struct {
		Properties map[string]struct {
			Type string `json:"type"`
		} `json:"properties"`
		Required []string `json:"required"`
	}
	for _, cardAction := range reviewerActionSchema.Actions {
		if cardAction.ActionKey == ActionReject {
			rejectSchema = cardAction.InputSchema
		}
	}
	require.Equal(t, "string", rejectSchema.Properties["reason"].Type)
	rejectActionCommand := app.AppNotificationActionCommand{
		MessageID: rejectCards[0].MessageID, ActionKey: ActionReject,
		CardRevision: 1, ClientActionID: "reject-app-installation",
		Input: map[string]any{"reason": "use the existing integration"},
	}
	_, err = notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, rejectActionCommand)
	require.NoError(t, err)
	dispatched, err = dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed)
	rejected, err := notifications.GetAppInstallationRequest(ctx, workspaceID, rejectedRequest.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusRejected, rejected.Status)
	require.Empty(t, rejected.InstallationID)
	require.Equal(t, "use the existing integration", rejected.DecisionReason)
	for _, card := range rejectCards {
		resolvedCard, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppNotificationStatusResolved, resolvedCard.Status)
	}
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2
  AND business_object_id = $3 AND content_kind = 'interactive_card'
`, workspaceID, RecipientBasisAppInstallationApplicant, rejectedRequest.ID))
	rejectedReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(rejectedRequest.ID),
		app.AppNotificationStatusResolved, "App 安装申请已拒绝：GitHub",
	)
	require.Equal(t, rejectedPendingReceipt.NotificationID, rejectedReceipt.NotificationID)
	require.Equal(t, rejectedPendingReceipt.MessageID, rejectedReceipt.MessageID)
	require.Equal(t, rejectedPendingReceipt.MessageSeq, rejectedReceipt.MessageSeq)
	requireApprovalCardTemplate(t, ctx, db, rejectedReceipt.MessageID, "")
	rejectedResult := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantResultClientNotificationID(rejectedRequest.ID),
		app.AppNotificationStatusActive, "你的 App 安装申请已拒绝：GitHub",
	)
	require.NotEqual(t, rejectedReceipt.NotificationID, rejectedResult.NotificationID)
	require.NotEqual(t, rejectedReceipt.MessageID, rejectedResult.MessageID)
	require.Equal(t, rejectedReceipt.ScopeID, rejectedResult.ScopeID)
	require.Greater(t, rejectedResult.MessageSeq, rejectedReceipt.MessageSeq)
	require.Equal(t, approvedResult.ScopeID, rejectedResult.ScopeID)
	require.Greater(t, rejectedResult.MessageSeq, approvedResult.MessageSeq)
	requireApprovalResultTemplate(t, ctx, db, rejectedResult.MessageID, 0)
	var renderedRejectedReason string
	require.NoError(t, db.QueryRow(ctx, `
SELECT JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.approval.reason'))
FROM service_notification_cards WHERE message_id = $1
`, rejectedResult.MessageID).Scan(&renderedRejectedReason))
	require.Equal(t, "use the existing integration", renderedRejectedReason)
	replayedRejectAction, err := notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, rejectActionCommand)
	require.NoError(t, err)
	require.True(t, replayedRejectAction.AlreadyAccepted)
	rejectedResultRecord, err := notifications.LoadAppNotification(ctx, workspaceID, rejectedResult.NotificationID)
	require.NoError(t, err)
	require.NotNil(t, rejectedResultRecord.BusinessObjectURL)
	require.Equal(t, "/apps/github", *rejectedResultRecord.BusinessObjectURL)
	requireNoConversationManualUnread(t, ctx, db, "mem_approval_bot_member", rejectedResult.ScopeID)

	inactiveRequest := requestVerifiedGitHubInstallationForApprovalAppTest(
		t, ctx, notifications, "usr_approval_bot_member", workspaceID, "request before leaving workspace",
	)
	inactiveCards, err := projector.ProjectPendingAppInstallationRequest(ctx, inactiveRequest)
	require.NoError(t, err)
	require.Len(t, inactiveCards, 2)
	inactivePendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(inactiveRequest.ID),
		app.AppNotificationStatusActive, "App 安装申请已提交：GitHub",
	)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2 AND business_object_id = $3
`, workspaceID, RecipientBasisAppInstallationApplicant, inactiveRequest.ID))
	mustExec(t, db, `UPDATE members SET status = 'inactive' WHERE id = 'mem_approval_bot_member'`)
	_, err = notifications.InvokeAppNotificationActionForUser(ctx, "usr_approval_bot_admin", workspaceID, app.AppNotificationActionCommand{
		MessageID: inactiveCards[0].MessageID, ActionKey: ActionReject,
		CardRevision: 1, ClientActionID: "reject-inactive-app-installation",
		Input: map[string]any{"reason": "requester left workspace"},
	})
	require.NoError(t, err)
	dispatched, err = dispatcher.Dispatch(ctx, appruntime.InternalDispatchParams{
		WorkspaceID: workspaceID, ServiceInstallationID: installationID,
		HandlerKey: HandlerKey, Limit: 10, Now: time.Now().UTC(),
	})
	require.NoError(t, err)
	require.Equal(t, 1, dispatched.Completed, "inactive applicant must not keep the committed decision delivery retrying")
	require.Zero(t, dispatched.Retried)
	inactiveDecided, err := notifications.GetAppInstallationRequest(ctx, workspaceID, inactiveRequest.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusRejected, inactiveDecided.Status)
	_, found, err := notifications.FindAppNotificationByClientID(
		ctx, workspaceID, installationID,
		appInstallationApplicantResultClientNotificationID(inactiveRequest.ID),
	)
	require.NoError(t, err)
	require.False(t, found, "inactive applicant must not receive a fresh Human-DM result")
	inactiveResolvedReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx, workspaceID, installationID,
		appInstallationApplicantReceiptClientNotificationID(inactiveRequest.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, app.AppNotificationStatusResolved, inactiveResolvedReceipt.Status)
	require.Equal(t, inactivePendingReceipt.NotificationID, inactiveResolvedReceipt.ID)
	require.Equal(t, inactivePendingReceipt.MessageID, inactiveResolvedReceipt.MessageID)
	inactiveResolvedResult, err := notifications.GetAppNotificationForBuiltin(
		ctx, IdentityID, workspaceID, inactiveResolvedReceipt.ID,
	)
	require.NoError(t, err)
	require.Equal(t, inactivePendingReceipt.MessageSeq, inactiveResolvedResult.MessageSeq)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*) FROM service_notifications
WHERE workspace_id = $1 AND recipient_basis_kind = $2 AND business_object_id = $3
`, workspaceID, RecipientBasisAppInstallationApplicant, inactiveRequest.ID))
	for _, card := range inactiveCards {
		inactiveReviewer, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppNotificationStatusResolved, inactiveReviewer.Status)
	}
}

func TestPendingAppInstallationApplicantReceiptSurvivesEmptyReviewerSet(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 15, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_empty_app_reviewers"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `
UPDATE members
SET status = 'inactive', updated_at = $2
WHERE workspace_id = $1
  AND type = 'human'
  AND workspace_role IN ('owner', 'admin')
`, workspaceID, now.Add(time.Second))

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	request := requestVerifiedGitHubInstallationForApprovalAppTest(
		t,
		ctx,
		notifications,
		"usr_approval_bot_member",
		workspaceID,
		"receipt must not depend on reviewer availability",
	)

	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.ErrorContains(t, err, "App installation request has no active workspace reviewer")
	require.Empty(t, cards)
	pendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(request.ID),
		app.AppNotificationStatusActive, "App 安装申请已提交：GitHub",
	)
	require.Equal(t, int64(1), pendingReceipt.Revision)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_TYPE(JSON_EXTRACT(action_schema_json, '$.actions')) = 'ARRAY'
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, pendingReceipt.MessageID), "applicant receipt must stay read-only even when reviewer projection fails")
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))

	mustExec(t, db, `
UPDATE members
SET status = 'active', updated_at = $2
WHERE workspace_id = $1
  AND id = 'mem_approval_bot_admin'
`, workspaceID, now.Add(2*time.Second))
	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
  AND business_object_version = 1
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationApplicant))

	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1
  AND service_definition_id = $2
  AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))
	replayedReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantReceiptClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, pendingReceipt.NotificationID, replayedReceipt.ID)
	require.Equal(t, pendingReceipt.MessageID, replayedReceipt.MessageID)
	require.Equal(t, int64(2), replayedReceipt.Revision)
	require.Equal(t, app.AppNotificationStatusActive, replayedReceipt.Status)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)
}

func TestAppInstallationProjectionReadsCoverMaximumReviewerFanoutAndApplicantReceipt(t *testing.T) {
	require.Greater(
		t,
		maxAppInstallationProjectionNotifications,
		maxAppInstallationReviewers+1,
		"projection reads must cover every reviewer plus the applicant receipt",
	)

	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 16, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_projection_limit"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	request := app.AppInstallationRequestView{
		ID:                     "svcinstreq_projection_limit",
		WorkspaceID:            workspaceID,
		AppName:                "GitHub",
		AppSlug:                "github",
		RequestKind:            app.AppInstallationRequestKindInstall,
		RequestedByMemberID:    "mem_approval_bot_member",
		RequestedByDisplayName: "mem_approval_bot_member",
		Status:                 app.AppInstallationRequestStatusPending,
		ApprovalRequired:       true,
	}
	recordingStore := &projectionLimitRecordingNotificationStore{resolved: request}
	projector := NewProjector(recordingStore, NewProvisioner(db))

	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 1)
	require.Equal(t, 2, recordingStore.createCount, "pending path creates one applicant receipt and one reviewer card")
	require.Equal(t, []int{maxAppInstallationProjectionNotifications}, recordingStore.listLimits)

	decided := request
	decided.Status = app.AppInstallationRequestStatusApproved
	decided.DecidedByMemberID = "mem_approval_bot_admin"
	recordingStore.resolved = decided
	_, err = projector.resolveAppInstallationCards(ctx, decided, "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, []int{
		maxAppInstallationProjectionNotifications,
		maxAppInstallationProjectionNotifications,
	}, recordingStore.listLimits, "pending and terminal paths must share the non-truncating projection read limit")
}

func TestApprovalAppApprovedAppApplicantResultOpensSetupUntilAppIsDeleted(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_bot_setup_result"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())

	const appSlug = "approval-result-app"
	requested, err := notifications.RequestCustomAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approvalAppTestAppOnboardingParams(appSlug),
	)
	require.NoError(t, err)
	require.Equal(t, []string{
		serviceactor.RuntimeTransportKindRunnerStream,
		serviceactor.RuntimeTransportKindCallback,
	}, requested.Request.AppRuntimeTransports)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, requested.Request)
	require.NoError(t, err)
	require.Len(t, cards, 1)

	approved, err := notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		requested.Request.ID,
		app.DecideAppInstallationRequestParams{Reason: "approved for setup"},
	)
	require.NoError(t, err)
	result, err := projector.ProjectAppInstallationRequestDecision(ctx, approved)
	require.NoError(t, err)
	require.NotEmpty(t, result.MessageID)

	var renderPayloadJSON, actionSchemaJSON []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT render_payload_json, action_schema_json
FROM service_notification_cards
WHERE message_id = $1
`, result.MessageID).Scan(&renderPayloadJSON, &actionSchemaJSON))
	var actionSchema struct {
		Actions []map[string]any `json:"actions"`
	}
	require.NoError(t, json.Unmarshal(actionSchemaJSON, &actionSchema))
	require.Equal(t, []map[string]any{{
		"actionKey": "open_app_setup",
		"label":     "配置应用",
		"semantics": "navigate",
		"type":      "open_url",
		"url":       "/apps/approval-result-app",
	}}, actionSchema.Actions)

	var holdCallbackSecret string
	require.NoError(t, db.QueryRow(ctx, `SELECT JSON_UNQUOTE(JSON_EXTRACT(COALESCE(config, JSON_OBJECT()), '$.callbackSigningSecret')) FROM service_definitions WHERE id = $1`, requested.Request.AppID).Scan(&holdCallbackSecret))
	require.NotEmpty(t, holdCallbackSecret)
	projected := string(renderPayloadJSON) + string(actionSchemaJSON)
	require.NotContains(t, projected, holdCallbackSecret)
	for _, forbidden := range []string{
		"runnerToken",
		"callbackSigningSecret",
		"callback_signing_secret",
		"token_hash",
	} {
		require.NotContains(t, projected, forbidden)
	}

	status, err := notifications.GetAppInitialCredentialsStatusForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approved.InstallationID,
	)
	require.NoError(t, err)
	require.Equal(t, app.AppInitialCredentialsStatusReadyToClaim, status.Status)

	claimed, err := notifications.ClaimAppInitialCredentialsForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approved.InstallationID,
	)
	require.NoError(t, err)
	require.NotEmpty(t, claimed.RunnerToken)
	require.NotEmpty(t, claimed.CallbackSigningSecret)
	var claimedCallbackSecret string
	require.NoError(t, db.QueryRow(ctx, `SELECT JSON_UNQUOTE(JSON_EXTRACT(COALESCE(config, JSON_OBJECT()), '$.callbackSigningSecret')) FROM service_definitions WHERE id = $1`, requested.Request.AppID).Scan(&claimedCallbackSecret))
	require.Equal(t, claimed.CallbackSigningSecret, claimedCallbackSecret)
	require.NotEqual(t, holdCallbackSecret, claimedCallbackSecret)

	status, err = notifications.GetAppInitialCredentialsStatusForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approved.InstallationID,
	)
	require.NoError(t, err)
	require.Equal(t, app.AppInitialCredentialsStatusClaimed, status.Status)
	_, err = notifications.ClaimAppInitialCredentialsForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approved.InstallationID,
	)
	require.Error(t, err)

	deleted, err := notifications.UninstallAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approved.InstallationID,
	)
	require.NoError(t, err)
	require.False(t, deleted.Enabled)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_installation_requests
WHERE workspace_id = $1 AND id = $2
`, workspaceID, requested.Request.ID))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1 AND id = $2
  AND business_object_id = $3
`, workspaceID, result.NotificationID, requested.Request.ID), "the Approval result remains as audit evidence")
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM scope_messages
WHERE workspace_id = $1 AND id = $2
`, workspaceID, result.MessageID), "the applicant's result message remains visible")

	var invalidatedRenderPayloadJSON, invalidatedActionSchemaJSON []byte
	var cardRevision int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT render_payload_json, action_schema_json, card_revision
FROM service_notification_cards
WHERE message_id = $1
`, result.MessageID).Scan(&invalidatedRenderPayloadJSON, &invalidatedActionSchemaJSON, &cardRevision))
	require.Equal(t, int64(2), cardRevision)
	var invalidatedRenderPayload map[string]any
	require.NoError(t, json.Unmarshal(invalidatedRenderPayloadJSON, &invalidatedRenderPayload))
	require.NotContains(t, invalidatedRenderPayload, "actions")
	var invalidatedActionSchema struct {
		Actions []map[string]any `json:"actions"`
	}
	require.NoError(t, json.Unmarshal(invalidatedActionSchemaJSON, &invalidatedActionSchema))
	require.Empty(t, invalidatedActionSchema.Actions)
	for _, removedActionValue := range []string{"open_app_setup", "open_url", "/apps/approval-result-app"} {
		require.NotContains(t, string(invalidatedRenderPayloadJSON), removedActionValue)
		require.NotContains(t, string(invalidatedActionSchemaJSON), removedActionValue)
	}

	auditCard, err := notifications.GetAppNotificationCardForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		result.MessageID,
	)
	require.NoError(t, err)
	require.Equal(t, int64(2), auditCard.CardRevision)
	require.JSONEq(t, string(invalidatedRenderPayloadJSON), string(auditCard.RenderPayloadJSON))

	const unprojectedSlug = "approval-unprojected-app"
	unprojected, err := notifications.RequestCustomAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		approvalAppTestAppOnboardingParams(unprojectedSlug),
	)
	require.NoError(t, err)
	pendingReviewerCards, err := projector.ProjectPendingAppInstallationRequest(ctx, unprojected.Request)
	require.NoError(t, err)
	require.Len(t, pendingReviewerCards, 1)
	pendingReviewerMessageID := pendingReviewerCards[0].MessageID
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, pendingReviewerMessageID))
	unprojectedApproved, err := notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		unprojected.Request.ID,
		app.DecideAppInstallationRequestParams{Reason: "approved without projecting the decision"},
	)
	require.NoError(t, err)
	_, err = notifications.UninstallAppInstallationForUser(
		ctx,
		"usr_approval_bot_member",
		workspaceID,
		unprojectedApproved.InstallationID,
	)
	require.NoError(t, err)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, pendingReviewerMessageID), "deleting a App removes actions from a reviewer card whose terminal projection did not run")
}

func approvalAppTestAppOnboardingParams(slug string) app.RequestCustomAppInstallationParams {
	return app.RequestCustomAppInstallationParams{
		Slug:   slug,
		Name:   "Approval Result App",
		Config: map[string]any{"tone": "brief"},
		Reason: "exercise the post-approval App setup handoff",
		Manifest: map[string]any{
			"schema_version": "1",
			"slug":           slug,
			"name":           "Approval Result App",
			"version":        "1.0.0",
			"callback": map[string]any{
				"url": "https://app.example.test/agentteam/callback",
			},
			"scopes": []string{app.AppMentionScopeRead, app.AppRuntimeScopeMessageWrite},
			"capabilities": map[string]any{
				"runtime": map[string]any{
					"runtime": map[string]any{
						"transports": []string{
							serviceactor.RuntimeTransportKindRunnerStream,
							serviceactor.RuntimeTransportKindCallback,
						},
						"default_transport": serviceactor.RuntimeTransportKindRunnerStream,
					},
					"events": []string{registry.AppEventMessageMention},
					"scopes": []string{app.AppMentionScopeRead, app.AppRuntimeScopeMessageWrite},
					"config_schema": map[string]any{
						"type":                 "object",
						"additionalProperties": false,
						"required":             []string{"tone"},
						"properties": map[string]any{
							"tone": map[string]any{
								"type": "string",
								"enum": []string{"brief", "detailed"},
							},
						},
					},
				},
			},
		},
	}
}

func TestApprovalAppWithdrawsPromotedAppRequesterWithoutApplicantResult(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 16, 9, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_promoted_app"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin' WHERE id = 'mem_approval_bot_reader'`)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())

	request := requestVerifiedGitHubInstallationForApprovalAppTest(
		t, ctx, notifications, "usr_approval_bot_member", workspaceID, "submitted before promotion",
	)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 2)
	pendingReceipt := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantReceiptClientNotificationID(request.ID),
		app.AppNotificationStatusActive, "App 安装申请已提交：GitHub",
	)
	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1
  AND service_definition_id = $2
  AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))
	mustExec(t, db, `
UPDATE members
SET workspace_role = 'admin', status = 'inactive', updated_at = NOW()
WHERE id = 'mem_approval_bot_member'
`)

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	settled, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusRejected, settled.Status)
	require.Equal(t, "mem_approval_bot_member", settled.DecidedByMemberID)
	require.Equal(t, app.PrivilegedAppRequestDecisionReason, settled.DecisionReason)
	require.True(t, app.IsPrivilegedAppRequestWithdrawalDecision(settled))
	for _, card := range cards {
		withdrawn, loadErr := notifications.LoadAppNotification(ctx, workspaceID, card.NotificationID)
		require.NoError(t, loadErr)
		require.Equal(t, app.AppNotificationStatusWithdrawn, withdrawn.Status)
		require.Equal(t, int64(2), withdrawn.Revision)
		var cardState string
		var cardRevision int64
		require.NoError(t, db.QueryRow(ctx, `
SELECT state, card_revision
FROM service_notification_cards
WHERE service_notification_id = $1
`, card.NotificationID).Scan(&cardState, &cardRevision))
		require.Equal(t, "withdrawn", cardState)
		require.Equal(t, int64(2), cardRevision)
	}
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND recipient_basis_kind = $2
  AND business_object_id = $3
`, workspaceID, RecipientBasisAppInstallationApplicant, request.ID))
	withdrawnReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantReceiptClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, app.AppNotificationStatusWithdrawn, withdrawnReceipt.Status)
	require.Equal(t, int64(2), withdrawnReceipt.Revision)
	require.Equal(t, pendingReceipt.NotificationID, withdrawnReceipt.ID)
	require.Equal(t, pendingReceipt.MessageID, withdrawnReceipt.MessageID)
	var receiptCardState string
	var receiptCardRevision int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT state, card_revision
FROM service_notification_cards
WHERE service_notification_id = $1
`, withdrawnReceipt.ID).Scan(&receiptCardState, &receiptCardRevision))
	require.Equal(t, app.AppNotificationStatusWithdrawn, receiptCardState)
	require.Equal(t, int64(2), receiptCardRevision)
	_, found, err = notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantResultClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.False(t, found, "a system-withdrawn privileged proposal must not append an applicant result")
	projectedResult, err := projector.ProjectAppInstallationRequestDecision(ctx, settled)
	require.NoError(t, err)
	require.Empty(t, projectedResult.NotificationID)
	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)

	// Simulate the losing side of the settlement race committing its active v1
	// applicant receipt after the sentinel decision's first withdrawal pass.
	// Reconciliation must rediscover the late card, replay idempotent sentinel
	// cleanup, and must never manufacture a terminal applicant result.
	mustExec(t, db, `
UPDATE service_notifications
SET status = 'active',
    revision = 1,
    business_object_version = 1,
    resolved_at = NULL,
    withdrawn_at = NULL,
    updated_at = $2
WHERE workspace_id = $1
  AND id = $3
`, workspaceID, now.Add(3*time.Second), withdrawnReceipt.ID)
	mustExec(t, db, `
UPDATE service_notification_cards
SET state = 'active', card_revision = 1, updated_at = $2
WHERE service_notification_id = $1
`, withdrawnReceipt.ID, now.Add(3*time.Second))
	lateReceipt, err := notifications.LoadAppNotification(ctx, workspaceID, withdrawnReceipt.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusActive, lateReceipt.Status)
	require.Equal(t, int64(1), lateReceipt.Revision)
	require.NotNil(t, lateReceipt.BusinessObjectVersion)
	require.Equal(t, int64(1), *lateReceipt.BusinessObjectVersion)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	lateWithdrawnReceipt, err := notifications.LoadAppNotification(ctx, workspaceID, withdrawnReceipt.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusWithdrawn, lateWithdrawnReceipt.Status)
	require.Equal(t, int64(2), lateWithdrawnReceipt.Revision)
	var lateReceiptCardState string
	var lateReceiptCardRevision int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT state, card_revision
FROM service_notification_cards
WHERE service_notification_id = $1
`, withdrawnReceipt.ID).Scan(&lateReceiptCardState, &lateReceiptCardRevision))
	require.Equal(t, app.AppNotificationStatusWithdrawn, lateReceiptCardState)
	require.Equal(t, int64(2), lateReceiptCardRevision)
	_, found, err = notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantResultClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.False(t, found, "late sentinel cleanup must not append an applicant result")
	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)

	// Promotion before the first projection settles the proposal without ever
	// creating a reviewer card. Restore member admission only long enough to
	// create a second request, then promote again.
	mustExec(t, db, `
UPDATE members
SET workspace_role = 'member', status = 'active', updated_at = NOW()
WHERE id = 'mem_approval_bot_member'
`)
	second := requestVerifiedGitHubInstallationForApprovalAppTest(
		t, ctx, notifications, "usr_approval_bot_member", workspaceID, "promoted before projection",
	)
	mustExec(t, db, `UPDATE members SET workspace_role = 'admin', updated_at = NOW() WHERE id = 'mem_approval_bot_member'`)
	secondCards, err := projector.ProjectPendingAppInstallationRequest(ctx, second)
	require.NoError(t, err)
	require.Empty(t, secondCards)
	secondSettled, err := notifications.GetAppInstallationRequest(ctx, workspaceID, second.ID)
	require.NoError(t, err)
	require.True(t, app.IsPrivilegedAppRequestWithdrawalDecision(secondSettled))
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
`, workspaceID, BusinessTypeAppInstallationRequest, second.ID))
}

func TestReconcileAppInstallationRequestProjectionsUsesRequestRowsAsDurableRetryLedger(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 15, 9, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_projection_reconcile"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())

	// Simulate a process failing after the source request commits but before
	// its first Approval create. No notification/outbox marker exists yet;
	// the request row itself must remain enough to recover the projection.
	request := requestGiteaInstallationForApprovalAppTest(
		t,
		ctx,
		notifications,
		"usr_approval_bot_member",
		workspaceID,
		"recover projection",
	)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID))

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 25)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
  AND business_object_version = 1
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationApplicant))

	var installationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_installations
WHERE workspace_id = $1
  AND service_definition_id = $2
  AND status = 'active'
`, workspaceID, DefinitionID).Scan(&installationID))

	// A terminal notification is not a usable pending reviewer card. Simulate
	// an anomalous post-projection resolve while the source request remains
	// pending; reconciliation must create a new active generation instead of
	// treating the historical row as converged.
	var originalReviewerNotificationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT id
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
LIMIT 1
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer).Scan(
		&originalReviewerNotificationID,
	))
	originalReviewer, err := notifications.LoadAppNotification(ctx, workspaceID, originalReviewerNotificationID)
	require.NoError(t, err)
	resolvedReviewer, err := notifications.MutateAppNotificationForBuiltin(
		ctx,
		IdentityID,
		workspaceID,
		app.AppNotificationMutationCommand{
			NotificationID:   originalReviewer.ID,
			Kind:             app.AppNotificationMutationResolve,
			ExpectedRevision: originalReviewer.Revision,
			ClientMutationID: "test:resolve-pending-reviewer",
			Content: &app.AppNotificationContent{
				Kind:         app.AppNotificationContentInteractiveCard,
				FallbackText: "Historical resolved reviewer card",
				CardJSON:     []byte(`{"schemaVersion":"1","title":"Historical resolved reviewer card"}`),
			},
			CausedByActor: app.AppNotificationActorRef{Type: "human", ID: "mem_approval_bot_admin"},
			CorrelationID: request.ID,
		},
	)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusResolved, resolvedReviewer.Status)
	stillPending, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusPending, stillPending.Status)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 25)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
  AND content_kind = 'interactive_card'
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	require.Equal(t, int64(1), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'resolved'
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	var replacementClientNotificationID string
	require.NoError(t, db.QueryRow(ctx, `
SELECT client_notification_id
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
LIMIT 1
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer).Scan(
		&replacementClientNotificationID,
	))
	require.Equal(
		t,
		appInstallationReviewerClientNotificationID(request.ID, "mem_approval_bot_admin")+":retry:2",
		replacementClientNotificationID,
	)

	// A completed active pending projection falls out of the gap scan; replay
	// never creates duplicate active cards.
	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 25)
	require.NoError(t, err)
	require.Zero(t, reconciled)

	// Simulate the same post-commit failure after a direct admin decision, then
	// independently resolve the reviewer card and pending applicant receipt. A
	// historical receipt is not the independently-created terminal result, so
	// reconciliation must still detect and append the missing fresh result.
	approved, err := notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		request.ID,
		app.DecideAppInstallationRequestParams{Reason: "approved"},
	)
	require.NoError(t, err)
	require.Equal(t, app.AppInstallationRequestStatusApproved, approved.Status)
	_, err = projector.resolveAppInstallationCards(ctx, approved, "", "", "", "")
	require.NoError(t, err)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	pendingReceipt, found, err := notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantReceiptClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.True(t, found)
	require.Equal(t, app.AppNotificationStatusActive, pendingReceipt.Status)
	resolvedReceiptContent, err := resolvedAppInstallationCardContent(approved, "Workspace Owner")
	require.NoError(t, err)
	terminalVersion := appInstallationRequestBusinessVersion(approved)
	resolvedReceipt, err := notifications.MutateAppNotificationForBuiltin(
		ctx,
		IdentityID,
		workspaceID,
		app.AppNotificationMutationCommand{
			NotificationID:        pendingReceipt.ID,
			Kind:                  app.AppNotificationMutationResolve,
			ExpectedRevision:      pendingReceipt.Revision,
			ClientMutationID:      "test:resolve-applicant-receipt-without-result",
			Content:               &resolvedReceiptContent,
			BusinessObjectVersion: &terminalVersion,
			CausedByActor: app.AppNotificationActorRef{
				Type: "human", ID: "mem_approval_bot_admin",
			},
			CorrelationID: request.ID,
		},
	)
	require.NoError(t, err)
	require.Equal(t, app.AppNotificationStatusResolved, resolvedReceipt.Status)
	_, found, err = notifications.FindAppNotificationByClientID(
		ctx,
		workspaceID,
		installationID,
		appInstallationApplicantResultClientNotificationID(request.ID),
	)
	require.NoError(t, err)
	require.False(t, found, "the resolved pending receipt is not the terminal result")

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 25)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
  AND status = 'active'
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer))
	require.Equal(t, int64(2), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
  AND recipient_basis_kind = $4
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationApplicant))
	result := requireApplicantNotification(
		t, ctx, db, notifications, workspaceID, "usr_approval_bot_member",
		appInstallationApplicantResultClientNotificationID(request.ID),
		app.AppNotificationStatusActive, "你的 App 安装申请已通过：Gitea",
	)
	require.NotEqual(t, resolvedReceipt.NotificationID, result.NotificationID)
	require.NotEqual(t, resolvedReceipt.MessageID, result.MessageID)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 25)
	require.NoError(t, err)
	require.Zero(t, reconciled)
}

func TestSetupPatchingRequestKeepsV1ReviewerCardApproveOnlyAndReconciles(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 22, 9, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_setup_patching"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	request := requestVerifiedGitHubInstallationForApprovalAppTest(
		t,
		ctx,
		notifications,
		"usr_approval_bot_member",
		workspaceID,
		"recover an ambiguous provider mutation",
	)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 1)

	mustExec(t, db, `
UPDATE service_installation_requests
SET status = $1, updated_at = $2
WHERE workspace_id = $3 AND id = $4
`, app.AppInstallationRequestStatusSetupPatching, now.Add(time.Second), workspaceID, request.ID)

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Equal(t, 1, reconciled)

	var status, textKey, actionKey string
	var actionCount int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT
  COALESCE(JSON_UNQUOTE(JSON_EXTRACT(card.render_payload_json, '$.status')), ''),
  COALESCE(JSON_UNQUOTE(JSON_EXTRACT(card.render_payload_json, '$.textKey')), ''),
  JSON_LENGTH(JSON_EXTRACT(card.render_payload_json, '$.actions')),
  COALESCE(JSON_UNQUOTE(JSON_EXTRACT(card.render_payload_json, '$.actions[0].actionKey')), '')
FROM service_notifications notification
JOIN service_notification_cards card
  ON card.service_notification_id = notification.id
 AND card.message_id = notification.message_id
WHERE notification.workspace_id = $1
  AND notification.business_object_type = $2
  AND notification.business_object_id = $3
  AND notification.recipient_basis_kind = $4
  AND notification.status = 'active'
LIMIT 1
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID, RecipientBasisAppInstallationReviewer).Scan(
		&status,
		&textKey,
		&actionCount,
		&actionKey,
	))
	require.Equal(t, app.AppInstallationRequestStatusSetupPatching, status)
	require.Equal(t, appInstallationSetupPatchingRetryTextKey, textKey)
	require.Equal(t, int64(1), actionCount)
	require.Equal(t, ActionApprove, actionKey)

	resolver := Resolver{}
	authorization := app.AppNotificationActionAuthorizationRequest{
		WorkspaceID: workspaceID,
		Basis: app.AppNotificationRecipientBasis{
			Kind: RecipientBasisAppInstallationReviewer,
			Ref:  "mem_approval_bot_admin",
		},
		BusinessObject: app.AppNotificationBusinessObject{
			Namespace: BusinessNamespace,
			Type:      BusinessTypeAppInstallationRequest,
			ID:        request.ID,
			Version:   1,
		},
		ActorMemberID: "mem_approval_bot_admin",
		ActionKey:     ActionApprove,
		CardRevision:  2,
		Now:           now.Add(2 * time.Second),
	}
	require.NoError(t, resolver.AuthorizeAppNotificationAction(ctx, db, authorization))
	authorization.ActionKey = ActionReject
	require.ErrorIs(t, resolver.AuthorizeAppNotificationAction(ctx, db, authorization), app.ErrForbidden)

	reconciled, err = projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)
}

func TestInternalSetupPatchingSagaNeverProjectsOrAuthorizesApproval(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_internal_setup_patching"
	seedApprovalAppWorkspace(t, db, workspaceID, now)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	request := requestVerifiedGitHubInstallationForApprovalAppTest(
		t,
		ctx,
		notifications,
		"usr_approval_bot_member",
		workspaceID,
		"seed an internal setup lease shape",
	)
	mustExec(t, db, `
UPDATE service_installation_requests
SET status = $1,
    approval_required = FALSE,
    request_reason = 'admin_complete:internal-saga',
    updated_at = $2
WHERE workspace_id = $3 AND id = $4
`, app.AppInstallationRequestStatusSetupPatching, now.Add(time.Second), workspaceID, request.ID)

	internal, err := notifications.GetAppInstallationRequest(ctx, workspaceID, request.ID)
	require.NoError(t, err)
	require.False(t, internal.ApprovalRequired)
	_, err = projector.ProjectPendingAppInstallationRequest(ctx, internal)
	require.ErrorContains(t, err, "not review-active")

	reconciled, err := projector.ReconcileAppInstallationRequestProjections(ctx, workspaceID, 10)
	require.NoError(t, err)
	require.Zero(t, reconciled)
	require.Equal(t, int64(0), rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notifications
WHERE workspace_id = $1
  AND business_object_type = $2
  AND business_object_id = $3
`, workspaceID, BusinessTypeAppInstallationRequest, request.ID))

	resolver := Resolver{}
	err = resolver.AuthorizeAppNotificationAction(ctx, db, app.AppNotificationActionAuthorizationRequest{
		WorkspaceID: workspaceID,
		Basis: app.AppNotificationRecipientBasis{
			Kind: RecipientBasisAppInstallationReviewer,
			Ref:  "mem_approval_bot_admin",
		},
		BusinessObject: app.AppNotificationBusinessObject{
			Namespace: BusinessNamespace,
			Type:      BusinessTypeAppInstallationRequest,
			ID:        request.ID,
			Version:   1,
		},
		ActorMemberID: "mem_approval_bot_admin",
		ActionKey:     ActionApprove,
		CardRevision:  1,
		Now:           now.Add(2 * time.Second),
	})
	require.ErrorIs(t, err, app.ErrForbidden)
}

func TestApprovalAppProvisionerRepairsMetadataAndUpgradesOriginalV2Once(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_policy_upgrade"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	provisioner := NewProvisioner(db)
	provisioned, err := provisioner.EnsureWorkspace(ctx, workspaceID, now)
	require.NoError(t, err)

	legacyBase := map[string]any{
		"recipient_policy_version":           int64(2),
		"allowed_recipient_basis_kinds":      []string{RecipientBasisApplicant, RecipientBasisAssignment},
		"allowed_business_object_namespaces": []string{BusinessNamespace},
		"allowed_business_object_types":      []string{BusinessTypeInstance},
		"allowed_human_dm_content_kinds":     []string{"interactive_card", "message"},
		"organization_policy_ref":            "agentteam.approval",
	}
	legacyDeterministic, err := json.Marshal(legacyBase)
	require.NoError(t, err)
	legacySum := sha256.Sum256(legacyDeterministic)
	legacyDigest := hex.EncodeToString(legacySum[:])
	legacyBase["policy_digest"] = legacyDigest
	legacyDocument, err := json.Marshal(legacyBase)
	require.NoError(t, err)
	mustExec(t, db, `
UPDATE service_grants
SET config = $2, policy_digest = $3, updated_at = $4
WHERE service_installation_id = $1
  AND scope = 'app.notifications.write' AND policy_version = 2
`, provisioned.Installation.ID, legacyDocument, legacyDigest, now)
	mustExec(t, db, `
UPDATE service_installations
SET metadata = '{"builtin":true,"identity":"agentteam.approval"}', updated_at = $2
WHERE id = $1
`, provisioned.Installation.ID, now)

	_, err = provisioner.EnsureWorkspace(ctx, workspaceID, now.Add(time.Second))
	require.NoError(t, err)
	_, err = provisioner.EnsureWorkspace(ctx, workspaceID, now.Add(2*time.Second))
	require.NoError(t, err)
	var version int64
	var config []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT policy_version, config
FROM service_grants
WHERE service_installation_id = $1
  AND scope = 'app.notifications.write' AND revoked_at IS NULL
`, provisioned.Installation.ID).Scan(&version, &config))
	require.Equal(t, int64(3), version)
	require.Contains(t, string(config), `"app_installation_reviewer"`)
	var repairedMetadata []byte
	require.NoError(t, db.QueryRow(ctx, `
SELECT metadata
FROM service_installations
WHERE id = $1
`, provisioned.Installation.ID).Scan(&repairedMetadata))
	require.JSONEq(t, approvalAppInstallationMetadataJSON, string(repairedMetadata))
}

func TestApprovalAppMemberDisplayNameUsesUserIdentityBeforeWorkspaceAlias(t *testing.T) {
	ctx := context.Background()
	db := startApprovalAppRollbackDB(t, ctx)
	now := time.Date(2026, 7, 16, 8, 0, 0, 0, time.UTC)
	workspaceID := "wsp_approval_bot_user_identity"
	seedApprovalAppWorkspace(t, db, workspaceID, now)
	mustExec(t, db, `UPDATE users SET display_name = '', name = 'looper applicant' WHERE id = 'usr_approval_bot_member'`)
	mustExec(t, db, `UPDATE members SET display_name = 'guominghao' WHERE id = 'mem_approval_bot_member'`)
	mustExec(t, db, `UPDATE users SET display_name = '', name = 'looper processor' WHERE id = 'usr_approval_bot_admin'`)
	mustExec(t, db, `UPDATE members SET display_name = 'workspace owner alias' WHERE id = 'mem_approval_bot_admin'`)

	notifications := app.New(db, app.Options{
		AppCredentialStorageKey: []byte("0123456789abcdef0123456789abcdef"),
	})
	projector := NewProjector(notifications, NewProvisioner(db))
	require.NoError(t, projector.Register())
	displayName, err := projector.memberDisplayName(
		ctx,
		workspaceID,
		"mem_approval_bot_member",
		"guominghao",
	)
	require.NoError(t, err)
	require.Equal(t, "looper applicant", displayName, "Approval cards must use the resolved user display name for both applicant and processor identities")

	missingMemberName, err := projector.memberDisplayName(
		ctx,
		workspaceID,
		"mem_historical_applicant",
		"Historical applicant",
	)
	require.NoError(t, err)
	require.Equal(t, "Historical applicant", missingMemberName, "durable request snapshots remain the fallback after membership deletion")

	unresolvedMemberName, err := projector.memberDisplayName(
		ctx,
		workspaceID,
		"mem_missing_approval_member",
		"",
	)
	require.NoError(t, err)
	require.Empty(t, unresolvedMemberName, "Approval cards must never expose raw member IDs")

	request := requestGiteaInstallationForApprovalAppTest(
		t,
		ctx,
		notifications,
		"usr_approval_bot_member",
		workspaceID,
		"resolved identity regression",
	)
	cards, err := projector.ProjectPendingAppInstallationRequest(ctx, request)
	require.NoError(t, err)
	require.Len(t, cards, 1)
	var pendingApplicantName string
	require.NoError(t, db.QueryRow(ctx, `
SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.request.applicantName')), '')
FROM service_notification_cards
WHERE message_id = $1
`, cards[0].MessageID).Scan(&pendingApplicantName))
	require.Equal(t, "looper applicant", pendingApplicantName)

	approved, err := notifications.ApproveAppInstallationRequestForUser(
		ctx,
		"usr_approval_bot_admin",
		workspaceID,
		request.ID,
		app.DecideAppInstallationRequestParams{Reason: "resolved identity approved"},
	)
	require.NoError(t, err)
	_, err = projector.ProjectAppInstallationRequestDecision(ctx, approved)
	require.NoError(t, err)
	var resolvedApplicantName, resolvedProcessorName string
	require.NoError(t, db.QueryRow(ctx, `
SELECT
  COALESCE(JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.request.applicantName')), ''),
  COALESCE(JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.approval.processorName')), '')
FROM service_notification_cards
WHERE message_id = $1
`, cards[0].MessageID).Scan(&resolvedApplicantName, &resolvedProcessorName))
	require.Equal(t, "looper applicant", resolvedApplicantName)
	require.Equal(t, "looper processor", resolvedProcessorName)
}

func requestGiteaInstallationForApprovalAppTest(
	t *testing.T,
	ctx context.Context,
	store *app.Store,
	requesterUserID string,
	workspaceID string,
	reason string,
) app.AppInstallationRequestView {
	t.Helper()
	request, err := store.RequestAppInstallationForUser(
		ctx,
		requesterUserID,
		workspaceID,
		app.RequestAppInstallationParams{
			AppSlug: "gitea",
			Config: map[string]any{
				"instance_url":   "https://gitea.example.test",
				"api_token":      "approval-app-gitea-token",
				"webhook_secret": "approval-app-gitea-webhook-secret",
				"display_name":   "Approval Gitea",
			},
			Reason: reason,
		},
	)
	require.NoError(t, err)
	return request
}

// requestVerifiedGitHubInstallationForApprovalAppTest creates a legacy
// no-target GitHub request for projection, rejection, withdrawal, and durable
// patching-card tests. It must never be used as a successful approval fixture:
// production GitHub setup approvals require at least one verified connection
// target and correctly classify this legacy shape as stale.
func requestVerifiedGitHubInstallationForApprovalAppTest(
	t *testing.T,
	ctx context.Context,
	store *app.Store,
	requesterUserID string,
	workspaceID string,
	reason string,
) app.AppInstallationRequestView {
	t.Helper()
	proposal, err := store.SaveVerifiedGitHubSetupProposalForUser(
		ctx,
		requesterUserID,
		workspaceID,
		provider.UpsertGitHubAppConfigParams{
			AppID:         42,
			AppSlug:       "agentteam-approval-test",
			AppName:       "AgentTeam Approval Test",
			HTMLURL:       "https://github.com/apps/agentteam-approval-test",
			ClientID:      "Iv1.approval-test",
			ClientSecret:  "approval-test-client-secret",
			PrivateKeyPEM: "approval-test-private-key",
			WebhookSecret: "approval-test-webhook-secret",
			OwnerLogin:    "approval-test-owner",
			OwnerID:       7,
		},
	)
	require.NoError(t, err)
	request, err := store.RequestAppInstallationForUser(
		ctx,
		requesterUserID,
		workspaceID,
		app.RequestAppInstallationParams{
			AppID:                 registry.GitHubAppID,
			RequestKind:           app.AppInstallationRequestKindInstall,
			GitHubSetupProposalID: proposal.ID,
			Reason:                reason,
		},
	)
	require.NoError(t, err)
	return request
}

func submitApprovalCreate(
	t *testing.T,
	ctx context.Context,
	service *serviceaccountflow.Service,
	workspaceID, suffix string,
	now time.Time,
) serviceaccountflow.WorkspaceCreateRequestResult {
	t.Helper()
	result, err := service.SubmitWorkspaceCreateRequest(ctx, serviceaccountflow.SubmitWorkspaceCreateRequest{
		WorkspaceID: workspaceID, RequesterUserID: "usr_approval_bot_member",
		ClientRequestID: "create-" + suffix,
		Payload: serviceaccountflow.WorkspaceCreatePayload{
			Name: "News " + suffix, Description: "Description " + suffix,
		},
		Now: now,
	})
	require.NoError(t, err)
	require.NotNil(t, result.Approval)
	require.Len(t, result.Assignments, 1)
	return result
}

func seedApprovalAppWorkspace(t testing.TB, db coredb.QueryExecer, workspaceID string, now time.Time) {
	t.Helper()
	organizationID := testfixture.SeedWorkspaceOrganization(t, context.Background(), db, workspaceID, now)
	mustExec(t, db, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, $1, $1, 0, $3, $3)
`, workspaceID, organizationID, now)
	for _, member := range []struct {
		id, userID, role string
	}{
		{id: "mem_approval_bot_admin", userID: "usr_approval_bot_admin", role: "admin"},
		{id: "mem_approval_bot_member", userID: "usr_approval_bot_member", role: "member"},
		{id: "mem_approval_bot_reader", userID: "usr_approval_bot_reader", role: "member"},
	} {
		mustExec(t, db, `
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, $3, '', FALSE, 'hash', $4, $4, $4)
`, member.userID, member.userID+"@example.com", member.id, now)
		mustExec(t, db, `
INSERT INTO members (
    id, workspace_id, type, display_name, user_id, workspace_role,
    status, created_at, updated_at
) VALUES ($1, $2, 'human', $1, $3, $4, 'active', $5, $5)
		`, member.id, workspaceID, member.userID, member.role, now)
	}
	allScopeID := "chn_approval_bot_all"
	mustExec(t, db, `
INSERT INTO scopes (
    id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
    archived_until_seq, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'channel', $3, 0, 0, 0, 'active', 'mem_approval_bot_admin', $4, $4)
`, allScopeID, workspaceID, "channel:"+allScopeID, now)
	mustExec(t, db, `
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, 'all', 'all', '', 'public', 'mem_approval_bot_admin', TRUE, $3, $3)
`, allScopeID, workspaceID, now)
	for _, memberID := range []string{"mem_approval_bot_admin", "mem_approval_bot_member", "mem_approval_bot_reader"} {
		require.NoError(t, message.EnsureScopeMembership(context.Background(), db, allScopeID, memberID, "public", "", "active", 0, 0, now))
	}
}

func rowCount(t testing.TB, ctx context.Context, db coredb.QueryRower, query string, args ...any) int64 {
	t.Helper()
	var count int64
	require.NoError(t, db.QueryRow(ctx, query, args...).Scan(&count))
	return count
}

func requireNoConversationManualUnread(
	t testing.TB,
	ctx context.Context,
	db coredb.QueryRower,
	memberID, scopeID string,
) {
	t.Helper()
	var count int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM conversation_member_states
WHERE member_id = $1 AND scope_id = $2
  AND (manual_unread_seq IS NOT NULL OR manual_unread_at IS NOT NULL)
`, memberID, scopeID).Scan(&count))
	require.Zero(t, count, "fresh results must not manufacture manual unread state on the old message")
}

func requireNaturalConversationUnread(
	t testing.TB,
	ctx context.Context,
	db coredb.QueryRower,
	memberID, scopeID string,
	expectedLastReadSeq, expectedUnread int64,
) {
	t.Helper()
	var lastReadSeq int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT last_read_seq
FROM scope_memberships
WHERE member_id = $1 AND scope_id = $2
  AND removed_at IS NULL AND effective_to_seq IS NULL
`, memberID, scopeID).Scan(&lastReadSeq))
	require.Equal(t, expectedLastReadSeq, lastReadSeq)
	var unread int64
	require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM scope_messages message
WHERE message.scope_id = $1
  AND message.seq > $2
  AND COALESCE(NULLIF(message.notify_principal_member_id, ''), message.author_member_id) <> $3
`, scopeID, lastReadSeq, memberID).Scan(&unread))
	require.Equal(t, expectedUnread, unread)
}

func requireApprovalCardTemplate(
	t testing.TB,
	ctx context.Context,
	db coredb.QueryRower,
	messageID, expected string,
) {
	t.Helper()
	var template string
	require.NoError(t, db.QueryRow(ctx, `
SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(render_payload_json, '$.template')), '')
FROM service_notification_cards
WHERE message_id = $1
`, messageID).Scan(&template))
	require.Equal(t, expected, template)
}

func requireApprovalResultTemplate(
	t testing.TB,
	ctx context.Context,
	db coredb.QueryRower,
	messageID string,
	expectedActionCount int64,
) {
	t.Helper()
	requireApprovalCardTemplate(t, ctx, db, messageID, "approval_result")
	require.Equal(t, expectedActionCount, rowCount(t, ctx, db, `
SELECT COUNT(*)
FROM service_notification_cards
WHERE message_id = $1
  AND JSON_TYPE(JSON_EXTRACT(action_schema_json, '$.actions')) = 'ARRAY'
  AND JSON_LENGTH(JSON_EXTRACT(action_schema_json, '$.actions')) > 0
`, messageID), "applicant result card action count")
}

type applicantNotificationSnapshot struct {
	NotificationID string
	ScopeID        string
	MessageID      string
	Status         string
	Revision       int64
	MessageSeq     int64
}

func requireApplicantNotification(
	t testing.TB,
	ctx context.Context,
	db coredb.QueryRower,
	notifications *app.Store,
	workspaceID, userID, clientNotificationID, expectedStatus, expectedText string,
) applicantNotificationSnapshot {
	t.Helper()
	var snapshot applicantNotificationSnapshot
	var contentKind string
	require.NoError(t, db.QueryRow(ctx, `
SELECT notification.id, notification.scope_id, notification.message_id,
       notification.status, notification.revision, message.seq,
       notification.content_kind
FROM service_notifications notification
JOIN scope_messages message
  ON message.workspace_id = notification.workspace_id
 AND message.scope_id = notification.scope_id
 AND message.id = notification.message_id
WHERE notification.workspace_id = $1
  AND notification.client_notification_id = $2
LIMIT 1
`, workspaceID, clientNotificationID).Scan(
		&snapshot.NotificationID, &snapshot.ScopeID, &snapshot.MessageID,
		&snapshot.Status, &snapshot.Revision, &snapshot.MessageSeq, &contentKind,
	))
	require.Equal(t, expectedStatus, snapshot.Status)
	require.Equal(t, app.AppNotificationContentInteractiveCard, contentKind)
	history, err := notifications.ListMessagesForUser(ctx, userID, workspaceID, snapshot.ScopeID, 20, nil, nil)
	require.NoError(t, err)
	for _, item := range history.Messages {
		if item.ID == snapshot.MessageID {
			require.Contains(t, item.Content, expectedText)
			return snapshot
		}
	}
	require.Fail(t, "Applicant Approval notification was not present in ordinary DM history")
	return snapshot
}
