fix: preserve verified reuse and prior context in file completion

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 22:46:09 -05:00
parent 5fda8df551
commit b3426bb5ce
5 changed files with 103 additions and 14 deletions

View File

@ -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

View File

@ -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<string, unknown> }).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<string, unknown> }).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 ?? {}),

View File

@ -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,
},
},

View File

@ -16,21 +16,46 @@ function record(value: unknown): Record<string, unknown> {
? value as Record<string, unknown> : {};
}
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<boolean> {
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;

View File

@ -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));