From 779913333a4c8a558e3f7e43c92f6ba96be9f875 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:29:24 -0600 Subject: [PATCH] fix(documents): say whether an exact revision read is still current (#19939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Task documents keep a revision history, and agents read and restore revisions through the API > - The exact-revision read returns one revision, but it does not say if that revision is still current > - An agent read the revision that it restored earlier by its ID and reported it as "latest", but another person had saved a newer edit > - This pull request adds the document's current revision to the exact-revision read > - The benefit is that a pinned read cannot pass as a read of the current document ## Linked Issues or Issue Description **What happened** In a chat eval, the agent restored a plan revision. Then another person saved a new revision. The user asked the agent to read the current plan. The agent called `GET /api/issues/{id}/documents/plan/revisions/{revisionId}` with the ID of its own restored revision. The response did not show that a newer revision existed. The agent reported the old revision as the latest and said that the read confirmed this. **Expected behavior** A read of one exact revision must show if that revision is still current, and which revision is current. Then the reader can see a newer edit and read the current document. **Steps to reproduce** 1. Save two revisions of a task plan. 2. Call `GET /api/issues/{id}/documents/plan/revisions/{firstRevisionId}`. 3. See that the response gives no sign that revision 2 is the current revision. ## What Changed - `getIssueDocumentRevision` now also returns `latestRevisionId`, `latestRevisionNumber` and `isLatest`. - Shared type `ExactDocumentRevision` describes this response. The UI client uses it. - The OpenAPI description of the exact-revision read names these fields and points to `GET /api/issues/{id}/documents/{key}` for the current text. - The service test checks `isLatest` for an older and a current revision. ## Verification - `cd server && pnpm exec vitest run src/__tests__/documents-service.test.ts src/__tests__/openapi-routes.test.ts` (42 passed). - `npx tsc --noEmit -p packages/shared` passes. ## Risks - Low risk. The change only adds fields to a read response. No caller removes or renames a field. No migration. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 6 ++++++ server/src/__tests__/documents-service.test.ts | 3 +++ server/src/routes/openapi.ts | 2 +- server/src/services/documents.ts | 3 ++- ui/src/api/issues.ts | 3 ++- 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 536c7ddccbf..04499d12da7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1220,6 +1220,7 @@ export type { IssueDocument, IssueDocumentSummary, DocumentRevision, + ExactDocumentRevision, DocumentFormat, LegacyPlanDocument, IssueAttachment, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 884c44fd39c..4aa18840240 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -820,6 +820,7 @@ export type { IssueDocument, IssueDocumentSummary, DocumentRevision, + ExactDocumentRevision, DocumentFormat, LegacyPlanDocument, IssueAncestor, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index f5a8eaa5493..f78d79c7449 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -214,6 +214,12 @@ export interface DocumentRevision { createdAt: Date; } +export interface ExactDocumentRevision extends DocumentRevision { + latestRevisionId: string | null; + latestRevisionNumber: number; + isLatest: boolean; +} + export interface LegacyPlanDocument { key: "plan"; body: string; diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index bab96aba79f..37909508ec7 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -109,6 +109,9 @@ describeEmbeddedPostgres("documentService system issue documents", () => { const pinned = await svc.getIssueDocumentRevision(issueId, "plan", original!.latestRevisionId!); expect(pinned?.body).toBe("# Plan"); expect(pinned?.id).toBe(original!.latestRevisionId); + const current = await svc.getIssueDocumentByKey(issueId, "plan"); + expect(pinned).toMatchObject({ isLatest: false, latestRevisionId: current!.latestRevisionId, latestRevisionNumber: 2 }); + expect(await svc.getIssueDocumentRevision(issueId, "plan", current!.latestRevisionId!)).toMatchObject({ isLatest: true, revisionNumber: 2, body: "# Newer plan" }); expect(await svc.getIssueDocumentRevision(issueId, "other-document", original!.latestRevisionId!)).toBeNull(); expect(await svc.getIssueDocumentRevision(randomUUID(), "plan", original!.latestRevisionId!)).toBeNull(); expect(await svc.getIssueDocumentRevision(issueId, "plan", randomUUID())).toBeNull(); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 405bfa1f99f..82c03636034 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4625,7 +4625,7 @@ registry.registerPath({ tags: ["issues"], summary: "Get an exact issue document revision", description: - "Available to board users and agents with current issue read access. Returns the specified revision only when its document is linked to this issue under the given key. Document keys are trimmed and lowercased before validation. This read does not restore or modify the document.", + "Available to board users and agents with current issue read access. Returns the specified revision only when its document is linked to this issue under the given key. Document keys are trimmed and lowercased before validation. The response also carries the document's current latestRevisionId and latestRevisionNumber, and isLatest says whether this revision is still current; to read the current text, use GET /api/issues/{id}/documents/{key}. This read does not restore or modify the document.", request: { params: z.object({ id: z.string(), diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 7c81d075354..fb8da036dad 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -181,11 +181,12 @@ export function documentService(db: Db) { format: documentRevisions.format, body: documentRevisions.body, changeSummary: documentRevisions.changeSummary, createdByAgentId: documentRevisions.createdByAgentId, createdByUserId: documentRevisions.createdByUserId, createdByRunId: documentRevisions.createdByRunId, createdAt: documentRevisions.createdAt, + latestRevisionId: documents.latestRevisionId, latestRevisionNumber: documents.latestRevisionNumber, }).from(issueDocuments) .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) .innerJoin(documentRevisions, and(eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, documents.companyId))) .where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, key), eq(documentRevisions.id, revisionId))).limit(1); - return revision ?? null; + return revision ? { ...revision, isLatest: revision.latestRevisionId === revision.id } : null; }, listIssueDocumentRevisions: async (issueId: string, rawKey: string) => { diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 178c0a521a9..803d206ef01 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,7 @@ import type { InboxMineSnapshot, CreateIssueTreeHold, DocumentRevision, + ExactDocumentRevision, FeedbackTargetType, FeedbackTrace, FeedbackVote, @@ -747,7 +748,7 @@ export const issuesApi = { `/issues/${id}/documents/${encodeURIComponent(key)}/revisions`, ), getDocumentRevision: (id: string, key: string, revisionId: string) => - api.get(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`), + api.get(`/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${encodeURIComponent(revisionId)}`), restoreDocumentRevision: (id: string, key: string, revisionId: string) => api.post( `/issues/${id}/documents/${encodeURIComponent(key)}/revisions/${revisionId}/restore`,