From b3426bb5ce7f6b47663d4fc28b5a067e76fd55cc Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 12 Sep 2026 22:46:09 -0500 Subject: [PATCH] fix: preserve verified reuse and prior context in file completion Co-Authored-By: Paperclip --- doc/execution-semantics.md | 6 ++- .../chat-attachment-reuse.test.ts | 50 ++++++++++++++++++ .../native-runtime/chat-attachment-reuse.ts | 4 ++ .../native-deliverable-feedback.ts | 51 ++++++++++++++----- .../native-runner-file-handoff.test.ts | 6 +++ 5 files changed, 103 insertions(+), 14 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index bb5c14a503..e00d910d7c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1212,7 +1212,11 @@ accessible work product registered by that run with a published URL. A prior run's output cannot stand in for a newly requested file. A same-run controller restart keeps the receipt; a replacement can inspect and re-register preserved workspace bytes without user bookkeeping. Follow-ups requesting no new file can -still reference existing downloads. A `workspace_file` locator alone is not delivery +still reference existing downloads. Prior downloads can also accompany a valid +current output as context. Authorized chat attachment reuse supplies a current-run +publication receipt for its verified clone; older reuse receipts must additionally +match an intact company-scoped source's filename, size, and hash. +A `workspace_file` locator alone is not delivery evidence: it neither verifies the file nor preserves its bytes after cleanup. Reading or reviewing an existing file for an inline answer does not require uploading that input. Ambiguous prose remains subject to the runner's diff --git a/server/src/services/native-runtime/chat-attachment-reuse.test.ts b/server/src/services/native-runtime/chat-attachment-reuse.test.ts index b51fb7f431..324dab123b 100644 --- a/server/src/services/native-runtime/chat-attachment-reuse.test.ts +++ b/server/src/services/native-runtime/chat-attachment-reuse.test.ts @@ -41,6 +41,7 @@ import { type ChatAttachmentReuseSource, } from "./chat-attachment-reuse.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; +import { validateNativeDeliverableEvidence } from "./native-deliverable-feedback.js"; describe("native same-conversation chat attachment reuse", () => { let temporary: Awaited< @@ -458,6 +459,8 @@ describe("native same-conversation chat attachment reuse", () => { attachmentId: expect.any(String), workProductId: expect.any(String), commentId: expect.any(String), + filename: "earlier.txt", + byteSize: sourceBody.length, sha256: createHash("sha256").update(sourceBody).digest("hex"), }, }); @@ -522,6 +525,53 @@ describe("native same-conversation chat attachment reuse", () => { }); expect(receipts["reuse-earlier-v1"]?.result).toEqual(first); expect(receipts["reuse-earlier-v2"]?.result).toEqual(duplicate); + const verifyReceipt = (semanticToolReceipts: unknown) => validateNativeDeliverableEvidence(db, { + companyId, issueId, runId, objective: "Send me that file again.", semanticToolReceipts, + }, { + schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Prepared the requested existing file.", + completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] }, + evidence: [{ ref: `deliverable:${preparedId}` }], verification: [], attentionRequests: [], artifacts: [], + }); + await expect(verifyReceipt(receipts)).resolves.toBeUndefined(); + for (const field of ["filename", "byteSize"]) { + const corrupted = structuredClone(receipts); + for (const receipt of Object.values(corrupted)) { + const prepared = (receipt.result as { prepared?: Record }).prepared; + if (prepared) prepared[field] = field === "filename" ? "different.txt" : sourceBody.length + 1; + } + await expect(verifyReceipt(corrupted)).rejects.toThrow("this run's requested output"); + } + const legacy = structuredClone(receipts); + for (const receipt of Object.values(legacy)) { + const prepared = (receipt.result as { prepared?: Record }).prepared; + if (prepared) { delete prepared.filename; delete prepared.byteSize; } + } + await expect(verifyReceipt(legacy)).resolves.toBeUndefined(); + const [sourceRow] = await db.select({ attachment: issueAttachments, asset: assets }).from(issueAttachments) + .innerJoin(assets, eq(assets.id, issueAttachments.assetId)).where(eq(issueAttachments.id, sourceAttachmentId)); + const foreignCompanyId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Unrelated receipt source" }); + try { + for (const mutation of ["missing", "filename", "size", "hash", "foreign company"]) { + if (mutation === "missing") await db.delete(issueAttachments).where(eq(issueAttachments.id, sourceAttachmentId)); + if (mutation === "filename") await db.update(assets).set({ originalFilename: "different.txt" }).where(eq(assets.id, sourceRow.asset.id)); + if (mutation === "size") await db.update(assets).set({ byteSize: sourceBody.length + 1 }).where(eq(assets.id, sourceRow.asset.id)); + if (mutation === "hash") await db.update(assets).set({ sha256: "0".repeat(64) }).where(eq(assets.id, sourceRow.asset.id)); + if (mutation === "foreign company") await db.update(issueAttachments).set({ companyId: foreignCompanyId }).where(eq(issueAttachments.id, sourceAttachmentId)); + try { + await expect(verifyReceipt(legacy)).rejects.toThrow("this run's requested output"); + // New receipts preserve the verified tuple even after the source is removed. + await expect(verifyReceipt(receipts)).resolves.toBeUndefined(); + } finally { + if (mutation === "missing") await db.insert(issueAttachments).values(sourceRow.attachment); + else await db.update(issueAttachments).set({ companyId }).where(eq(issueAttachments.id, sourceAttachmentId)); + await db.update(assets).set({ originalFilename: sourceRow.asset.originalFilename, + byteSize: sourceRow.asset.byteSize, sha256: sourceRow.asset.sha256 }).where(eq(assets.id, sourceRow.asset.id)); + } + } + } finally { + await db.delete(companies).where(eq(companies.id, foreignCompanyId)); + } const completedResult = mergeHeartbeatRunResultJson( { ...(run.resultJson ?? {}), diff --git a/server/src/services/native-runtime/chat-attachment-reuse.ts b/server/src/services/native-runtime/chat-attachment-reuse.ts index ecfd9b6256..61b7f1b727 100644 --- a/server/src/services/native-runtime/chat-attachment-reuse.ts +++ b/server/src/services/native-runtime/chat-attachment-reuse.ts @@ -180,6 +180,8 @@ export type PreparedReusedChatAttachment = { attachmentId: string; workProductId: string; commentId: string; + filename: string; + byteSize: number; sha256: string; }; }; @@ -1530,6 +1532,8 @@ export async function prepareReusedChatAttachment(input: { attachmentId: attachment.id, workProductId: attachment.artifactWorkProductId, commentId: comment.id, + filename: input.source.filename, + byteSize: body.length, sha256: attachment.sha256, }, }, diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index 6341f12ed8..72b3f8afeb 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -16,21 +16,46 @@ function record(value: unknown): Record { ? value as Record : {}; } -function hasCurrentPublicationReceipt(receipts: unknown, attachment: { +async function hasCurrentPublicationReceipt(db: Db, companyId: string, receipts: unknown, attachment: { id: string; filename: string | null; byteSize: number; sha256: string; -}): boolean { - return Object.values(record(receipts)).some(value => { +}): Promise { + for (const value of Object.values(record(receipts))) { const receipt = record(value); const input = record(receipt.input); const result = record(receipt.result); - return receipt.operationId === "register_deliverable" && - (result.disposition === "applied" || result.disposition === "duplicate") && - result.commandId === `deliverable-prepared:${attachment.id}` && - Array.isArray(result.entityRefs) && result.entityRefs[0] === attachment.id && - typeof input.filename === "string" && input.filename.trim() === attachment.filename && - input.byteSize === attachment.byteSize && - typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase(); - }); + if ((result.disposition !== "applied" && result.disposition !== "duplicate") || + !Array.isArray(result.entityRefs) || result.entityRefs[0] !== attachment.id) continue; + if (receipt.operationId === "register_deliverable" && + result.commandId === `deliverable-prepared:${attachment.id}` && + typeof input.filename === "string" && input.filename.trim() === attachment.filename && + input.byteSize === attachment.byteSize && + typeof input.sha256 === "string" && input.sha256.trim().toLowerCase() === attachment.sha256.toLowerCase()) return true; + if (receipt.operationId !== "reuse_chat_attachment" || + result.commandId !== `chat-attachment-reused:${attachment.id}`) continue; + const prepared = record(result.prepared); + const source = record(result.source); + if (prepared.attachmentId !== attachment.id || + source.attachmentId !== input.attachmentId || source.commentId !== input.sourceCommentId || + typeof prepared.sha256 !== "string" || prepared.sha256.toLowerCase() !== attachment.sha256.toLowerCase() || + typeof source.sha256 !== "string" || source.sha256.toLowerCase() !== attachment.sha256.toLowerCase()) continue; + if ("filename" in prepared || "byteSize" in prepared) { + if (prepared.filename === attachment.filename && prepared.byteSize === attachment.byteSize) return true; + continue; + } + // Older committed reuse receipts contain the authenticated source and hash, + // but not its filename/size. Only an intact, matching source can supply those + // missing facts; this does not authorize a new reuse or bypass its tool gate. + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + if (typeof source.attachmentId !== "string" || !uuid.test(source.attachmentId) || + typeof source.commentId !== "string" || !uuid.test(source.commentId)) continue; + const [original] = await db.select({ filename: assets.originalFilename, byteSize: assets.byteSize, sha256: assets.sha256 }) + .from(issueAttachments).innerJoin(assets, and(eq(assets.id, issueAttachments.assetId), eq(assets.companyId, companyId))) + .where(and(eq(issueAttachments.id, source.attachmentId), eq(issueAttachments.companyId, companyId), + eq(issueAttachments.issueCommentId, source.commentId))).limit(1); + if (original?.filename === attachment.filename && original.byteSize === attachment.byteSize && + original.sha256.toLowerCase() === attachment.sha256.toLowerCase()) return true; + } + return false; } /** Recognize explicit output requests, not incidental mentions of source files. @@ -94,8 +119,8 @@ export async function validateNativeDeliverableEvidence( // this run published the newly requested output. The receipt survives a // controller restart of this run; a replacement can re-register preserved // workspace bytes internally rather than asking the user to confirm them. - if (fileRequested && (attachment.originatingRunId !== binding.runId || - !hasCurrentPublicationReceipt(binding.semanticToolReceipts, attachment))) { + if (fileRequested && attachment.originatingRunId !== binding.runId) continue; + if (fileRequested && !await hasCurrentPublicationReceipt(db, binding.companyId, binding.semanticToolReceipts, attachment)) { throw new Error("This attachment has no matching verified publication receipt for this run's requested output. Inspect any preserved file and use register_deliverable to verify its current filename, size, and SHA-256, then cite the new receipt. No human completion approval was created."); } registeredAttachment = true; diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index 31377d162a..fe5ce7b834 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -1184,6 +1184,12 @@ describe("native runner file handoff", () => { // Feedback reloads the durable receipt, so a controller restart of the same run keeps this proof. await expect(nativeCompletionFeedback(db, nextRunId, doneReport([`deliverable:${current.entityRefs[0]}`]))) .resolves.toContain("Completion report accepted"); + await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `deliverable:${current.entityRefs[0]}`]))) + .resolves.toContain("Completion report accepted"); + const [currentProduct] = await db.insert(issueWorkProducts).values({ companyId, issueId, type: "artifact", provider: "external", + title: "Current report", status: "ready_for_review", url: "https://example.com/current.pdf", createdByRunId: nextRunId }).returning(); + await expect(nativeCompletionFeedback(db, nextRunId, doneReport([ref, `work_product:${currentProduct.id}`]))) + .resolves.toContain("Completion report accepted"); } finally { await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); await db.delete(issueWorkProducts).where(eq(issueWorkProducts.id, product.id));