diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 1065ea1727..9c03bc7715 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -818,6 +818,7 @@ Core authorization follows these rules: - An agent targeting any user other than its resolved responsible user requires an explicit `inbox:manage` grant. Grants may be unscoped or constrained by `scope.userIds`. - Archive and unarchive operations are company-scoped, reversible, and activity logged with actor, agent, run, target user, target-resolution source, and policy mode. - New qualifying issue activity may invalidate an archive so the item resurfaces; archival is not a substitute for resolving or closing work. +- Viewing an issue may update its per-user read receipt, but read receipts alone do not enroll the issue in Mine. Mine participation begins with a user-authored comment, issue creation/assignment, or another audited user mutation; explicit product actions such as manually running a routine may record an audited inbox touch. Ownership split: diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index fe97cc4b75..a96c5e3d4b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -18,6 +18,7 @@ import { issueInboxArchives, issueDocuments, issuePlanDecompositions, + issueReadStates, issueRelations, issueThreadInteractions, issues, @@ -308,6 +309,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { await db.delete(issueRelations); await db.delete(issueDocuments); await db.delete(issueInboxArchives); + await db.delete(issueReadStates); await db.delete(activityLog); await db.delete(issues); await db.delete(documents); @@ -336,6 +338,70 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { return companyId; } + it("does not treat passive issue activity as touching it, but includes real user mutations", async () => { + const companyId = await seedAssignableAgentCompany(); + const issueId = randomUUID(); + const userId = "board-user"; + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Issue viewed without participation", + status: "todo", + priority: "medium", + }); + await svc.markRead(companyId, issueId, userId); + await db.insert(activityLog).values([ + { + companyId, + actorType: "user", + actorId: userId, + action: "issue.read_marked", + entityType: "issue", + entityId: issueId, + }, + { + companyId, + actorType: "user", + actorId: userId, + action: "issue.file_resource_content_read", + entityType: "issue", + entityId: issueId, + }, + { + companyId, + actorType: "user", + actorId: userId, + action: "issue.file_resource_download_denied", + entityType: "issue", + entityId: issueId, + }, + { + companyId, + actorType: "user", + actorId: userId, + action: "issue.tree_control_previewed", + entityType: "issue", + entityId: issueId, + }, + ]); + + await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([]); + + await db.insert(activityLog).values({ + companyId, + actorType: "user", + actorId: userId, + action: "issue.comment_cancelled", + entityType: "issue", + entityId: issueId, + }); + + await expect(svc.list(companyId, { touchedByUserId: userId })).resolves.toEqual([ + expect.objectContaining({ id: issueId }), + ]); + }); + function agentRow(companyId: string, input: { id: string; name: string; diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 9c98324956..9ffcf04f7e 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -16,7 +16,6 @@ import { heartbeatRuns, instanceSettings, issueInboxArchives, - issueReadStates, issues, projectWorkspaces, projects, @@ -64,7 +63,6 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { } await db.delete(activityLog); await db.delete(issueInboxArchives); - await db.delete(issueReadStates); await db.delete(secretAccessEvents); await db.delete(companySecretBindings); await db.delete(routineRuns); @@ -429,7 +427,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(agentId).not.toBe(otherAgentId); }); - it("fires for a human comment and ignores pure-read activity", async () => { + it("fires for a human comment and ignores inbox bookkeeping activity", async () => { const { companyId, projectId, routine, svc } = await seedFixture(); const windowStart = new Date(Date.now() - 60_000); const now = new Date(); @@ -455,6 +453,15 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { entityId: issueId, createdAt: new Date(windowStart.getTime() + 2_000), }, + { + companyId, + actorType: "user", + actorId: "user-1", + action: "issue.inbox_touched", + entityType: "issue", + entityId: issueId, + createdAt: new Date(windowStart.getTime() + 3_000), + }, ]); await expect(svc.evaluateActivityGate(routine, now)).resolves.toMatchObject({ @@ -482,6 +489,15 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { entityId: issueId, createdAt: new Date(windowStart.getTime() + 2_000), }, + { + companyId, + actorType: "user", + actorId: "user-1", + action: "issue.inbox_touched", + entityType: "issue", + entityId: issueId, + createdAt: new Date(windowStart.getTime() + 3_000), + }, ]); await expect(svc.evaluateActivityGate(routine, now)).resolves.toMatchObject({ fire: false }); @@ -1240,12 +1256,15 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)), ).resolves.toHaveLength(0); await expect( - db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)), + db.select().from(activityLog).where(eq(activityLog.entityId, previousIssue.id)), ).resolves.toEqual([ expect.objectContaining({ companyId, - issueId: previousIssue.id, - userId, + actorType: "user", + actorId: userId, + action: "issue.inbox_touched", + entityType: "issue", + entityId: previousIssue.id, }), ]); @@ -1323,12 +1342,15 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)), ).resolves.toHaveLength(0); await expect( - db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)), + db.select().from(activityLog).where(eq(activityLog.entityId, previousIssue.id)), ).resolves.toEqual([ expect.objectContaining({ companyId, - issueId: previousIssue.id, - userId, + actorType: "user", + actorId: userId, + action: "issue.inbox_touched", + entityType: "issue", + entityId: previousIssue.id, }), ]); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 34b1e594b8..fb34306ff1 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1317,6 +1317,64 @@ async function getWorkspaceInheritanceIssue( return issue; } +// Mine participation fails closed. Add new user-authored issue mutation actions +// here instead of admitting every issue activity, because reads, previews, and +// denied resource requests are audited too. +const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [ + "issue.accepted_plan_decomposition_updated", + "issue.admin_force_release", + "issue.approval_linked", + "issue.approval_unlinked", + "issue.approvers_updated", + "issue.assigned", + "issue.attachment_added", + "issue.attachment_removed", + "issue.blockers.updated", + "issue.blockers_updated", + "issue.checked_out", + "issue.checkout", + "issue.child_created", + "issue.comment_cancelled", + "issue.document_annotation_comment_added", + "issue.document_annotation_remapped", + "issue.document_annotation_thread_created", + "issue.document_annotation_thread_resolved", + "issue.document_deleted", + "issue.document_locked", + "issue.document_restored", + "issue.document_unlocked", + "issue.document_updated", + "issue.document_upserted", + "issue.feedback_vote_saved", + "issue.inbox_touched", + "issue.low_trust_output_promoted", + "issue.monitor_cleared", + "issue.monitor_scheduled", + "issue.recovery_action_resolved", + "issue.relations.updated", + "issue.released", + "issue.reviewers_updated", + "issue.scheduled_retry_retry_now", + "issue.successful_run_handoff_resolved", + "issue.task_watchdog_fingerprint_reviewed", + "issue.thread_interaction_accepted", + "issue.thread_interaction_answered", + "issue.thread_interaction_cancelled", + "issue.thread_interaction_created", + "issue.thread_interaction_item_verdicts_submitted", + "issue.thread_interaction_withdrawn", + "issue.tree_cancel_status_updated", + "issue.tree_hold_created", + "issue.tree_hold_released", + "issue.tree_restore_status_updated", + "issue.updated", + "issue.watchdog_created", + "issue.watchdog_removed", + "issue.work_product_created", + "issue.work_product_deleted", + "issue.work_product_updated", +] as const; + function touchedByUserCondition(companyId: string, userId: string) { return sql` ( @@ -1324,10 +1382,16 @@ function touchedByUserCondition(companyId: string, userId: string) { OR ${issues.assigneeUserId} = ${userId} OR EXISTS ( SELECT 1 - FROM ${issueReadStates} - WHERE ${issueReadStates.issueId} = ${issues.id} - AND ${issueReadStates.companyId} = ${companyId} - AND ${issueReadStates.userId} = ${userId} + FROM ${activityLog} + WHERE ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} = ${issues.id}::text + AND ${activityLog.companyId} = ${companyId} + AND ${activityLog.actorType} = 'user' + AND ${activityLog.actorId} = ${userId} + AND ${activityLog.action} IN (${sql.join( + ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map((action) => sql`${action}`), + sql`, `, + )}) ) OR EXISTS ( SELECT 1 diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 330d96053e..279232552f 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -16,7 +16,6 @@ import { goals, heartbeatRuns, issueInboxArchives, - issueReadStates, issues, pluginManagedResources, plugins, @@ -88,6 +87,7 @@ const ACTIVITY_GATE_IGNORED_ACTIONS = [ "issue.read_unmarked", "issue.inbox_archived", "issue.inbox_unarchived", + "issue.inbox_touched", ]; const WEEKDAY_INDEX: Record = { Sun: 0, @@ -1583,22 +1583,17 @@ export function routineService( touchedAt: Date; }, ) { - await executor - .insert(issueReadStates) - .values({ - companyId: input.companyId, - issueId: input.issueId, - userId: input.userId, - lastReadAt: input.touchedAt, - updatedAt: input.touchedAt, - }) - .onConflictDoUpdate({ - target: [issueReadStates.companyId, issueReadStates.issueId, issueReadStates.userId], - set: { - lastReadAt: input.touchedAt, - updatedAt: input.touchedAt, - }, - }); + await executor.insert(activityLog).values({ + companyId: input.companyId, + actorType: "user", + actorId: input.userId, + action: "issue.inbox_touched", + entityType: "issue", + entityId: input.issueId, + responsibleUserId: input.userId, + details: { source: "manual_routine_run" }, + createdAt: input.touchedAt, + }); await executor .delete(issueInboxArchives)