mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
fix(attention): treat app-connection approvals as governed confirmations
An accepted connectionAuthorization card answers an app connection, not an ordinary sign-off. It no longer settles older ordinary confirmations, and the summary payload keeps the flag. The governed keys now match the set used in issue-question-context and the other confirmation readers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d23b0f3aff
commit
a36923c7fc
3 files changed
+19
-12
No files matched your search
@@ -1649,16 +1649,19 @@ describeEmbeddedPostgres("attention service", () => {
|
|||||||
expect(feed.items.filter((item) => item.sourceKind === "issue_thread_interaction").map((item) => item.subject.id)).toEqual([reAskId]);
|
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 () => {
|
it.each([
|
||||||
const { companyId, workerId, reviewerId } = await seedCompany("ATS");
|
["secret proposal", "ATS", { secretProposal: { proposalId: "11111111-1111-4111-8111-111111111111" } }],
|
||||||
const issueId = await insertIssue({ companyId, identifier: "ATS-1", title: "Secret then sign-off", status: "in_review", assigneeAgentId: workerId });
|
["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 ordinaryId = randomUUID();
|
||||||
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none" };
|
const base = { companyId, issueId, kind: "request_confirmation", continuationPolicy: "none" };
|
||||||
await db.insert(issueThreadInteractions).values([
|
await db.insert(issueThreadInteractions).values([
|
||||||
{ ...base, id: ordinaryId, status: "pending", createdByAgentId: workerId, title: "Approve the plan", payload: { version: 1, prompt: "Approve the plan?" },
|
{ ...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") },
|
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",
|
{ ...base, id: randomUUID(), status: "accepted", createdByAgentId: reviewerId, title: "Governed action",
|
||||||
payload: { version: 1, prompt: "Store this key?", secretProposal: { proposalId: randomUUID() } },
|
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") },
|
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[]);
|
] as typeof issueThreadInteractions.$inferInsert[]);
|
||||||
const service = attentionService(db);
|
const service = attentionService(db);
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ function payloadFlag(payload: unknown, key: string) {
|
|||||||
return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record<string, unknown>)[key]);
|
return Boolean(payload && typeof payload === "object" && key in payload && (payload as Record<string, unknown>)[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) {
|
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
|
* ordinary confirmation — pending, or already answered/expired. Without the
|
||||||
* settled case, answering the newest card (from chat or anywhere else)
|
* settled case, answering the newest card (from chat or anywhere else)
|
||||||
* resurfaces the older ask it replaced. Quality approvals stay
|
* resurfaces the older ask it replaced. Quality approvals stay
|
||||||
* independent; tool-action and secret-proposal approvals gate one specific
|
* independent; tool-action, secret-proposal and app-connection approvals gate
|
||||||
* action, so they neither hide nor are hidden by a settled confirmation.
|
* 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
|
* Only an ask to the same recipient settles it, so one person's answer never
|
||||||
* hides a request addressed to someone else.
|
* hides a request addressed to someone else.
|
||||||
*/
|
*/
|
||||||
@@ -84,8 +89,7 @@ export async function loadNewestSettledConfirmationAt(db: Db, companyId: string,
|
|||||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||||
inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]),
|
inArray(issueThreadInteractions.status, [...SETTLED_CONFIRMATION_STATUSES]),
|
||||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`,
|
sql`jsonb_typeof(${issueThreadInteractions.payload}->'qualityReview') is distinct from 'object'`,
|
||||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'toolAction') is distinct from 'object'`,
|
sql`not (${issueThreadInteractions.payload} ?| array['toolAction', 'secretProposal', 'connectionAuthorization'])`,
|
||||||
sql`jsonb_typeof(${issueThreadInteractions.payload}->'secretProposal') is distinct from 'object'`,
|
|
||||||
)).groupBy(issueThreadInteractions.issueId, issueThreadInteractions.addresseeUserId, issueThreadInteractions.addresseeAgentId);
|
)).groupBy(issueThreadInteractions.issueId, issueThreadInteractions.addresseeUserId, issueThreadInteractions.addresseeAgentId);
|
||||||
return new Map(rows.map((row) => [confirmationAudienceKey(row), new Date(row.createdAt)]));
|
return new Map(rows.map((row) => [confirmationAudienceKey(row), new Date(row.createdAt)]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1179,7 +1179,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
|
|||||||
status: issueThreadInteractions.status,
|
status: issueThreadInteractions.status,
|
||||||
title: issueThreadInteractions.title,
|
title: issueThreadInteractions.title,
|
||||||
summary: issueThreadInteractions.summary,
|
summary: issueThreadInteractions.summary,
|
||||||
payload: summaryOnly ? sql<Record<string, unknown>>`jsonb_build_object('target', ${issueThreadInteractions.payload}->'target', 'qualityReview', ${issueThreadInteractions.payload}->'qualityReview', 'toolAction', ${issueThreadInteractions.payload}->'toolAction', 'secretProposal', ${issueThreadInteractions.payload}->'secretProposal')` : issueThreadInteractions.payload,
|
payload: summaryOnly ? sql<Record<string, unknown>>`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,
|
addresseeAgentId: issueThreadInteractions.addresseeAgentId,
|
||||||
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
addresseeUserId: issueThreadInteractions.addresseeUserId,
|
||||||
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
createdByAgentId: issueThreadInteractions.createdByAgentId,
|
||||||
|
|||||||
Reference in new issue
Block a user