Keep interaction continuations scoped to the target task

Separate an interaction's producer from explicit resume history. Do not
import another task's comments or results. Filter newly captured foreign
origins and recover older inherited origins only when the saved producer
context and comment row prove their source. Keep missing context and
company boundaries fail-closed.

Verified 46 continuation tests, 201 related recovery tests, and server
typecheck. Added 20 database regressions for provenance and scope guards.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-21 20:06:05 -07:00
1 parent 3c70b3d008
commit ded156a904
3 files changed
+205 -19

No files matched your search

+4
View File
@@ -376,6 +376,10 @@ Document-scoped activity may still route work when it is converted into an expli
Freeform document approval text is not auto-acceptance. Plan approval, implementation approval, or review acceptance must flow through the explicit interaction, approval, execution-policy, assignment, or blocker primitives that define who owns the next move.
An interaction can be created by a run on another task in the same company, or by a run with no task. That run records the interaction's origin; its comments and results do not become context for the target task. Explicit resume and retry history takes precedence and must belong to the target task.
New interactions exclude known comments from other tasks when recording their origins. At dispatch, older interactions may discard a copied origin only when both the producer's saved context and a comment on that producer's other task prove where it came from. Missing records, cross-company references, explicit wake comments, and unrelated origins still fail closed.
### Comment interrupts and ownership handoffs
A board comment can be an interrupt, an ownership change, both, or neither. Paperclip must keep those concepts separate in the product contract.
@@ -0,0 +1,155 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { buildExecutionContinuation, currentContinuationOrigins } from "./execution-continuation.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("interaction producer task scope", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("paperclip-continuation-provenance-");
db = createDb(database.connectionString);
}, 30_000);
afterAll(async () => { await database?.cleanup(); });
async function fixture() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), producerIssueId = randomUUID();
const producerRunId = randomUUID(), interactionId = randomUUID(), targetCommentId = randomUUID(), producerCommentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Provenance fixture", issuePrefix: `PRV${companyId.slice(0, 8)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Executor", role: "engineer", adapterType: "paperclip_runner" });
await db.insert(issues).values([
{ id: issueId, companyId, title: "Target task", status: "in_progress", assigneeAgentId: agentId },
{ id: producerIssueId, companyId, title: "Producer task", status: "in_progress", assigneeAgentId: agentId },
]);
await db.insert(issueComments).values([
{ id: targetCommentId, companyId, issueId, authorType: "user", authorUserId: "local-board", body: "Keep the target direction." },
{ id: producerCommentId, companyId, issueId: producerIssueId, authorType: "user", authorUserId: "local-board", body: "Unrelated producer direction." },
]);
const producerContext = { issueId: producerIssueId, commentId: producerCommentId };
await db.insert(heartbeatRuns).values({ id: producerRunId, companyId, agentId, status: "succeeded",
contextSnapshot: producerContext,
resultJson: { summary: "Unrelated producer work.", apiToolReceipts: { saved: { state: "completed", operationId: "producer_operation", result: "Unrelated result." } } },
});
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId,
kind: "ask_user_questions", status: "answered", sourceRunId: producerRunId,
originCommentIds: [producerCommentId, targetCommentId],
resolvedByUserId: "local-board", resolvedAt: new Date(),
payload: { version: 1, questions: [{ id: "scope", prompt: "Which scope?", selectionMode: "single", options: [{ id: "target", label: "Target" }] }] },
result: { version: 1, answers: [{ questionId: "scope", optionIds: ["target"] }] },
});
const build = (context: Record<string, unknown> = {}) => buildExecutionContinuation({
db, companyId, issueId, agentId, context: { interactionId, wakeReason: "ask_user_questions.answered", ...context },
summary: null, exposeLowTrustRaw: false,
});
return { companyId, agentId, issueId, producerIssueId, producerRunId, interactionId, targetCommentId, producerCommentId, producerContext, build };
}
it("does not copy another task's comments into newly captured interaction origins", async () => {
const f = await fixture();
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, f.producerContext)).toEqual([f.targetCommentId]);
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentId: f.targetCommentId })).toEqual([f.targetCommentId]);
});
it("retains unknown and cross-company origins so dispatch can reject them", async () => {
const f = await fixture(), other = await fixture(), missing = randomUUID();
const ids = [missing, "not-a-uuid", other.targetCommentId, f.producerCommentId];
expect(await currentContinuationOrigins(db, f.companyId, f.issueId, { commentIds: ids }))
.toEqual([missing, "not-a-uuid", other.targetCommentId, f.targetCommentId]);
});
it("resumes a human answer with legacy producer origins without importing the producer task", async () => {
const f = await fixture();
const envelope = await f.build();
expect(envelope.trigger.sourceRunId).toBe(f.producerRunId);
expect(envelope.originCommentIds).toEqual([f.targetCommentId]);
expect(envelope.messages.map(row => row.id)).toEqual([f.targetCommentId]);
expect(envelope.objective).toBe("Keep the target direction.");
expect(envelope.humanResponses).toEqual([expect.objectContaining({ id: f.interactionId,
result: { answers: [{ questionId: "scope", optionIds: ["target"], otherText: undefined }] } })]);
expect(envelope.completedWork).toBeNull();
expect(envelope.completedActions).toEqual([]);
expect(JSON.stringify(envelope)).not.toContain("Unrelated");
});
it("accepts a taskless producer as provenance when the interaction has target-scoped origins", async () => {
const f = await fixture();
await db.update(heartbeatRuns).set({ contextSnapshot: {} }).where(eq(heartbeatRuns.id, f.producerRunId));
await db.update(issueThreadInteractions).set({ originCommentIds: [f.targetCommentId] }).where(eq(issueThreadInteractions.id, f.interactionId));
const envelope = await f.build();
expect(envelope.trigger.sourceRunId).toBe(f.producerRunId);
expect(envelope.completedWork).toBeNull();
expect(envelope.messages).toHaveLength(1);
});
it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("prefers explicit %s history to the producer", async (key) => {
const f = await fixture(), resumeRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: resumeRunId, companyId: f.companyId, agentId: f.agentId,
status: "interrupted", contextSnapshot: { issueId: f.issueId, commentId: f.targetCommentId },
resultJson: { summary: "Target work.", apiToolReceipts: { saved: { state: "completed", operationId: "target_operation", result: "Target result." } } },
});
const envelope = await f.build({ [key]: resumeRunId });
expect(envelope.trigger.sourceRunId).toBe(resumeRunId);
expect(envelope.completedWork).toBe("Target work.");
expect(envelope.completedActions).toEqual([expect.objectContaining({ runId: resumeRunId, operationId: "target_operation" })]);
expect(envelope.originCommentIds).toEqual([f.targetCommentId]);
expect(JSON.stringify(envelope)).not.toContain("Unrelated");
});
it.each(["retryOfRunId", "previousRunId", "interruptedRunId"])("rejects explicit %s history from another task", async (key) => {
const f = await fixture();
await expect(f.build({ [key]: f.producerRunId })).rejects.toThrow("continuation_source_context_missing");
});
it("retains the explicit user continuation authorization requirement", async () => {
const f = await fixture();
await expect(f.build({ explicitUserContinuation: { previousRunId: f.producerRunId } }))
.rejects.toThrow("continuation_user_authorization_missing");
});
it.each(["sourceCommentId", "commentId"])("rejects an explicit foreign %s even when it matches the producer", async (key) => {
const f = await fixture();
if (key === "sourceCommentId") await db.update(issueThreadInteractions)
.set({ sourceCommentId: f.producerCommentId }).where(eq(issueThreadInteractions.id, f.interactionId));
await expect(f.build(key === "commentId" ? { commentId: f.producerCommentId } : {}))
.rejects.toThrow("continuation_source_context_missing");
});
it.each(["missing", "cross-company", "unrecorded", "other-task"])("rejects %s origins instead of silently discarding them", async (kind) => {
const f = await fixture();
let invalidId = randomUUID();
if (kind === "cross-company") invalidId = (await fixture()).targetCommentId;
if (kind === "unrecorded") {
invalidId = f.producerCommentId;
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.producerIssueId } }).where(eq(heartbeatRuns.id, f.producerRunId));
} else {
if (kind === "other-task") {
const thirdIssueId = randomUUID();
await db.insert(issues).values({ id: thirdIssueId, companyId: f.companyId, title: "Unrelated third task" });
await db.insert(issueComments).values({ id: invalidId, companyId: f.companyId, issueId: thirdIssueId, body: "Third task." });
}
await db.update(heartbeatRuns).set({ contextSnapshot: { ...f.producerContext, commentIds: [invalidId] } }).where(eq(heartbeatRuns.id, f.producerRunId));
}
await db.update(issueThreadInteractions).set({ originCommentIds: [invalidId] }).where(eq(issueThreadInteractions.id, f.interactionId));
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
});
it("rejects a producer outside the company", async () => {
const f = await fixture(), other = await fixture();
await db.update(issueThreadInteractions).set({ sourceRunId: other.producerRunId }).where(eq(issueThreadInteractions.id, f.interactionId));
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
});
it("rejects a missing explicit resume instead of falling back to the producer", async () => {
const f = await fixture();
await expect(f.build({ retryOfRunId: randomUUID() })).rejects.toThrow("continuation_source_context_missing");
});
it("rejects a missing target origin even when legacy producer origins are recoverable", async () => {
const f = await fixture();
await db.delete(issueComments).where(eq(issueComments.id, f.targetCommentId));
await expect(f.build()).rejects.toThrow("continuation_source_context_missing");
});
});
+46 -19
View File
@@ -69,6 +69,18 @@ export async function currentContinuationOrigins(
issueId: string,
context: unknown,
): Promise<string[]> {
const candidates = continuationOriginCommentIds(context);
// A run may create an interaction on another task. Its own comments do not
// become authority on that task. Keep unknown references for dispatch to
// reject, rather than silently claiming complete context.
const foreignComments = candidates.length
? await db.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, companyId),
sql`${issueComments.issueId} != ${issueId}`,
inArray(sql<string>`${issueComments.id}::text`, candidates),
))
: [];
const foreignIds = new Set(foreignComments.map(row => row.id));
const [latest] = await db
.select({ id: issueComments.id })
.from(issueComments)
@@ -86,7 +98,7 @@ export async function currentContinuationOrigins(
.limit(1);
return [
...new Set([
...continuationOriginCommentIds(context),
...candidates.filter(id => !foreignIds.has(id)),
...(latest ? [latest.id] : []),
]),
];
@@ -144,33 +156,48 @@ export async function buildExecutionContinuation(input: {
);
const explicitContinuation = object(input.context.explicitUserContinuation);
const explicitUserSource = string(explicitContinuation.previousRunId);
const sourceRunId =
const resumeSourceRunId =
explicitUserSource ??
triggerInteraction?.sourceRunId ??
string(input.context.retryOfRunId) ??
string(input.context.previousRunId) ??
string(input.context.interruptedRunId);
const sourceRun = sourceRunId
? (
await db
.select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.id, sourceRunId),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
)[0]
: null;
if (sourceRunId && !sourceRun)
const producerRunId = triggerInteraction?.sourceRunId ?? null;
const sourceRunId = resumeSourceRunId ?? producerRunId;
const loadRun = async (id: string) => (await db
.select({ context: heartbeatRuns.contextSnapshot, result: heartbeatRuns.resultJson })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.id, id)))
)[0];
const candidate = sourceRunId ? await loadRun(sourceRunId) : null;
// An interaction producer is provenance. Explicit resume history must still
// belong to this task, and only task-scoped content can enter the envelope.
const sourceRun = object(candidate?.context).issueId === issueId ? candidate : null;
if ((sourceRunId && !candidate) || (resumeSourceRunId && !sourceRun))
throw new Error(explicitUserSource ? "continuation_user_authorization_missing" : "continuation_source_context_missing");
const producer = producerRunId === sourceRunId ? candidate
: producerRunId ? await loadRun(producerRunId) : null;
if (producerRunId && !producer) throw new Error("continuation_source_context_missing");
const producerIssueId = string(object(producer?.context).issueId);
const producerOrigins = new Set(continuationOriginCommentIds(producer?.context));
const recordedOrigins = triggerInteraction?.originCommentIds ?? [];
const inheritedOrigins = recordedOrigins.filter(id => producerOrigins.has(id));
// Older interactions copied the producer's origins without task scope.
// Ignore only references proven to be comments on that producer's other
// task. Missing rows, unrelated references, and explicit wake origins keep
// the existing fail-closed check below.
const inheritedForeignComments = producerIssueId && producerIssueId !== issueId && inheritedOrigins.length
? await db.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.companyId, companyId),
sql`${issueComments.issueId}::text = ${producerIssueId}`,
inArray(sql<string>`${issueComments.id}::text`, inheritedOrigins),
))
: [];
const inheritedForeignIds = new Set(inheritedForeignComments.map(row => row.id));
const originCommentIds = [
...new Set([
...continuationOriginCommentIds(input.context),
...continuationOriginCommentIds(sourceRun?.context),
...(triggerInteraction?.originCommentIds ?? []),
...recordedOrigins.filter(id => !inheritedForeignIds.has(id)),
...(triggerInteraction?.sourceCommentId
? [triggerInteraction.sourceCommentId]
: []),