diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 6be15a2b0d4..fdf4044fefb 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -1649,16 +1649,19 @@ describeEmbeddedPostgres("attention service", () => { expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([reAskId]); }); - it("does not let a settled secret proposal hide an older ordinary confirmation", async () => { - const { companyId, workerId, reviewerId } = await seedCompany("ATS"); - const issueId = await insertIssue({ companyId, identifier: "ATS-1", title: "Secret then sign-off", status: "in_review", assigneeAgentId: workerId }); + it.each([ + ["secret proposal", "ATS", { secretProposal: { proposalId: "11111111-1111-4111-8111-111111111111" } }], + ["app connection", "ATK", { connectionAuthorization: { connectionId: "22222222-2222-4222-8222-222222222222" } }], + ] as const)("does not let a settled %s hide an older ordinary confirmation", async (_label, prefix, governed) => { + const { companyId, workerId, reviewerId } = await seedCompany(prefix); + const issueId = await insertIssue({ companyId, identifier: `${prefix}-1`, title: "Governed card then sign-off", status: "in_review", assigneeAgentId: workerId }); const ordinaryId = randomUUID(); const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none" }; await db.insert(issueThreadInteractions).values([ { ...base, id: ordinaryId, status: "pending", createdByAgentId: workerId, title: "Approve the plan", payload: { version: 1, prompt: "Approve the plan?" }, createdAt: new Date("2026-11-05T12:00:00.000Z"), updatedAt: new Date("2026-11-05T12:00:00.000Z") }, - { ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "Store API key", - payload: { version: 1, prompt: "Store this key?", secretProposal: { proposalId: randomUUID() } }, + { ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "Governed action", + payload: { version: 1, prompt: "Allow this?", ...governed }, createdAt: new Date("2026-11-05T12:05:00.000Z"), updatedAt: new Date("2026-11-05T12:06:00.000Z"), resolvedAt: new Date("2026-11-05T12:06:00.000Z") }, ] as typeof issueThreadInteractions.$inferInsert[]); const service = attentionService(db); diff --git a/server/src/services/attention-source-eligibility.ts b/server/src/services/attention-source-eligibility.ts index bfe3ed1adb8..be7772f9e80 100644 --- a/server/src/services/attention-source-eligibility.ts +++ b/server/src/services/attention-source-eligibility.ts @@ -19,9 +19,13 @@ function payloadFlag(payload: unknown, key: string) { return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record)[key]); } -/** Tool actions and secret proposals gate one specific action; creation never supersedes siblings for them either. */ +/** Payload keys that bind a confirmation to one specific action (tool call, secret, app connection). */ +const GOVERNED_CONFIRMATION_PAYLOAD_KEYS = ["toolAction", "secretProposal", "connectionAuthorization"] as const; + +/** Governed confirmations gate one action; answering one never answers an ordinary ask, and vice versa. */ function governedAction(payload: unknown) { - return payloadFlag(payload, "toolAction") || payloadFlag(payload, "secretProposal"); + // Truthy, not key presence: the summary read projects absent keys as JSON null. + return GOVERNED_CONFIRMATION_PAYLOAD_KEYS.some((key) => payloadFlag(payload, key)); } /** @@ -29,8 +33,9 @@ function governedAction(payload: unknown) { * ordinary confirmation — pending, or already answered/expired. Without the * settled case, answering the newest card (from chat or anywhere else) * resurfaces the older ask it replaced. Quality approvals stay - * independent; tool-action and secret-proposal approvals gate one specific - * action, so they neither hide nor are hidden by a settled confirmation. + * independent; tool-action, secret-proposal and app-connection approvals gate + * one specific action, so they neither hide nor are hidden by a settled + * confirmation. * Only an ask to the same recipient settles it, so one person's answer never * hides a request addressed to someone else. */ @@ -84,8 +89,7 @@ export async function loadNewestSettledConfirmationAt(db: Db, companyId: string, eq(issueThreadInteractions.kind, "request_confirmation"), inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]), sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`, - sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`, - sql`jsonb_typeof(${issueThreadInteractions.payload}->'secretProposal') is distinct from 'object'`, + sql`not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization'])`, )).groupBy(issueThreadInteractions.issueId, issueThreadInteractions.addresseeUserId, issueThreadInteractions.addresseeAgentId); return new Map(rows.map((row) => [confirmationAudienceKey(row), new Date(row.createdAt)])); } diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 69843682c72..44f2c844cf1 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -1179,7 +1179,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions status: issueThreadInteractions.status, title: issueThreadInteractions.title, summary: issueThreadInteractions.summary, - payload: summaryOnly ? sql>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal')` : issueThreadInteractions.payload, + payload: summaryOnly ? sql>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal', 'connectionAuthorization', ${issueThreadInteractions.payload}->'connectionAuthorization')` : issueThreadInteractions.payload, addresseeAgentId: issueThreadInteractions.addresseeAgentId, addresseeUserId: issueThreadInteractions.addresseeUserId, createdByAgentId: issueThreadInteractions.createdByAgentId,