diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 2b0bb888ca0..c69c928eb65 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -34,6 +34,16 @@ approvals do not authorize the proposal. A newer pending or rejected proposal su older acceptance. A saved plan requires approval of its current document revision; checkbox decisions retain the user's actual selected scope. +A later accepted execution proposal can be untargeted when the unchanged saved +plan already has its own human approval. Admission validates both decisions. It +uses the latest plan proposal, so pending, rejected, invalid, or stale plan +approval cannot fall back to an older acceptance. A later pending or rejected +execution proposal still stops creation. Accepted checkbox plan confirmations +also appear in native task context and authorize normal accepted-plan decomposition. +Native context includes `acceptedPlan.approvedOptions` for checkbox decisions, +with the saved selected IDs and option text. It does not substitute the card's +defaults or unchecked options, including after a later execution confirmation. + Planning documents, questions and proposal cards remain available before approval. Ordinary task delegation and previously committed task-creation replay retain their existing behavior, including dispatch recovery for already admitted work. diff --git a/server/src/__tests__/onboarding-first-task-admission.test.ts b/server/src/__tests__/onboarding-first-task-admission.test.ts index 2c0933a6191..9659fe55bf9 100644 --- a/server/src/__tests__/onboarding-first-task-admission.test.ts +++ b/server/src/__tests__/onboarding-first-task-admission.test.ts @@ -582,6 +582,143 @@ describe.skipIf(!support.supported)("onboarding first-task execution admission", await expect(create(f)).rejects.toThrow(/proposal.*accept/i); }); + const acceptFollowUp = async (f: Awaited>, p: Awaited>) => { + const card = await p.svc.create(p.issue, { kind: "request_confirmation", + payload: { version: 1, prompt: "Create the first read-only discovery task from the approved plan?" }, + sourceRunId: f.runId, + }, { agentId: f.agentId, runId: f.runId }); + await p.svc.acceptInteraction(p.issue, card.id, {}, { userId: f.responsibleUserId! }); + return card; + }; + + it("retains approval of the current plan through a later accepted execution proposal", async () => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + await acceptFollowUp(f, p); + await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } }); + }); + + it.each(["pending", "rejected"])("keeps a later %s execution proposal behind its human gate", async status => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + const card = await p.svc.create(p.issue, { kind: "request_confirmation", + payload: { version: 1, prompt: "Create the next execution task?" }, + }, { agentId: f.agentId }); + if (status === "rejected") await p.svc.rejectInteraction(p.issue, card.id, {}, { userId: f.responsibleUserId! }); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + }); + + it.each(["pending", "rejected", "agent-only", "invalid-selection", "revised-plan", "foreign-source"])( + "a later accepted proposal cannot replace %s plan approval", async scenario => { + const f = await fixture(); + const p = await proposal(f, true); + if (scenario === "rejected") await p.svc.rejectInteraction(p.issue, p.card.id, {}, { userId: f.responsibleUserId! }); + else if (scenario === "agent-only") await p.svc.acceptInteraction(p.issue, p.card.id, { selectedOptionIds: ["execute"] }, { agentId: f.agentId, runId: f.runId }); + else if (scenario !== "pending") await accept(f, p); + if (scenario === "invalid-selection") await server.db.update(issueThreadInteractions) + .set({ result: { version: 1, outcome: "accepted", selectedOptionIds: ["unapproved"] } }).where(eq(issueThreadInteractions.id, p.card.id)); + if (scenario === "foreign-source") { + const other = await fixture(); + await server.db.update(issueThreadInteractions).set({ sourceRunId: other.runId }).where(eq(issueThreadInteractions.id, p.card.id)); + } + if (scenario === "revised-plan") { + await documentService(server.db).unlockIssueDocument(f.issueId, "plan"); + await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", + baseRevisionId: p.plan!.latestRevisionId, body: "Changed scope requires a new plan decision." }); + } + await acceptFollowUp(f, p); + const before = await f.snapshot(); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + expect((await f.snapshot()).issues).toEqual(before.issues); + }); + + it("does not revive an older plan approval after a newer rejected plan proposal", async () => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + const replacement = await p.svc.create(p.issue, { kind: "request_confirmation", payload: { + version: 1, prompt: "Approve this plan again?", target: { + type: "issue_document", key: "plan", issueId: f.issueId, + documentId: p.plan!.id, revisionId: p.plan!.latestRevisionId!, + }, + } }, { agentId: f.agentId }); + await p.svc.rejectInteraction(p.issue, replacement.id, {}, { userId: f.responsibleUserId! }); + await acceptFollowUp(f, p); + await expect(create(f)).rejects.toThrow(/proposal.*accept/i); + }); + + it("decomposes a plan accepted through the onboarding checkbox card", async () => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!; + const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/accepted-plan-decompositions`, { + method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ acceptedPlanRevisionId: p.plan!.latestRevisionId, + children: [{ title: "First discovery task", status: "backlog", workMode: "standard", priority: "medium", assigneeAgentId: f.agentId }] }), + }); + const data = await response.json(); + expect(response.status, JSON.stringify(data)).toBe(200); + expect(data.childIssueIds).toHaveLength(1); + }); + + it("includes a checkbox-approved plan in the native task context", async () => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + await expect(f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} })) + .resolves.toMatchObject({ acceptedPlan: { revisionId: p.plan!.latestRevisionId } }); + }); + + it.each([false, true])("retains only the selected plan scope after a later execution approval (anchored=%s)", async anchored => { + const f = await fixture(); + const [issue] = await server.db.select().from(issues).where(eq(issues.id, f.issueId)); + const plan = (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", + format: "markdown", body: "First: read-only discovery. Later: implementation, only if approved." })).document; + const svc = issueThreadInteractionService(server.db); + const selected = { id: "discovery", label: "Read-only discovery", description: "Inspect and write the architecture document." }; + const card = await svc.create(issue, { kind: "request_checkbox_confirmation", payload: { + version: 1, prompt: "Which plan steps may start?", minSelected: 1, maxSelected: 2, + defaultSelectedOptionIds: ["discovery", "implementation"], + options: [selected, { id: "implementation", label: "Change the application" }], + target: { type: "issue_document", key: "plan", issueId: f.issueId, documentId: plan.id, revisionId: plan.latestRevisionId! }, + } }, { agentId: f.agentId }); + await svc.acceptInteraction(issue, card.id, { selectedOptionIds: [selected.id] }, { userId: f.responsibleUserId! }); + await acceptFollowUp(f, { issue, plan, svc, card }); + if (anchored) await server.db.update(heartbeatRuns).set({ contextSnapshot: { + issueId: f.issueId, planReviewInteraction: { acceptedTargetRevision: { revisionId: plan.latestRevisionId } }, + } }).where(eq(heartbeatRuns.id, f.runId)); + const context = await f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} }) as { + acceptedPlan: { revisionId: string; approvedOptions?: unknown[] }; + }; + expect(context.acceptedPlan.revisionId).toBe(plan.latestRevisionId); + expect(context.acceptedPlan.approvedOptions).toEqual([selected]); + const [saved] = await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, card.id)); + expect(saved.result).toMatchObject({ selectedOptionIds: [selected.id] }); + }); + + it.each([ + { selectedOptionIds: [] }, + { selectedOptionIds: ["unapproved"] }, + { selectedOptionIds: ["execute", "execute"] }, + ])("does not publish or decompose an invalid checkbox selection $selectedOptionIds", async ({ selectedOptionIds }) => { + const f = await fixture(); + const p = await proposal(f, true); + await accept(f, p); + await server.db.update(issueThreadInteractions).set({ result: { version: 1, outcome: "accepted", selectedOptionIds } }) + .where(eq(issueThreadInteractions.id, p.card.id)); + await expect(f.authority.execute({ tool: "get_task_context", callId: randomUUID(), arguments: {} })) + .resolves.toMatchObject({ acceptedPlan: null }); + const before = await f.snapshot(); + await expect(issueService(server.db).decomposeAcceptedPlan(f.issueId, { + acceptedPlanRevisionId: p.plan!.latestRevisionId!, actorAgentId: f.agentId, actorRunId: f.runId, + children: [{ title: "Unapproved work", status: "backlog", workMode: "standard", priority: "medium" }], + })).rejects.toThrow(/accepted plan confirmation/); + expect((await f.snapshot()).issues).toEqual(before.issues); + }); + it.each(["stale", "foreign-target", "empty-selection"])("does not execute a plan with %s approval", async kind => { const f = await fixture(); const p = await proposal(f, true); diff --git a/server/src/services/confirmation-decision.ts b/server/src/services/confirmation-decision.ts new file mode 100644 index 00000000000..e474258ab63 --- /dev/null +++ b/server/src/services/confirmation-decision.ts @@ -0,0 +1,36 @@ +import { + requestCheckboxConfirmationPayloadSchema, + requestCheckboxConfirmationResultSchema, + requestConfirmationPayloadSchema, + requestConfirmationResultSchema, + type RequestCheckboxConfirmationOption, +} from "@paperclipai/shared"; + +export function readPersistedConfirmationDecision(row: { + kind: string; + status: string; + payload: unknown; + result: unknown; + resolvedAt: Date | null; +}) { + if ((row.kind !== "request_confirmation" && row.kind !== "request_checkbox_confirmation") + || (row.status !== "accepted" && row.status !== "rejected") || !row.resolvedAt) return null; + const checkbox = row.kind === "request_checkbox_confirmation"; + const payload = checkbox ? requestCheckboxConfirmationPayloadSchema.safeParse(row.payload) + : requestConfirmationPayloadSchema.safeParse(row.payload); + const result = checkbox ? requestCheckboxConfirmationResultSchema.safeParse(row.result) + : requestConfirmationResultSchema.safeParse(row.result); + if (!payload.success || !result.success || result.data.outcome !== row.status) return null; + let approvedOptions: RequestCheckboxConfirmationOption[] | null = null; + if (checkbox && row.status === "accepted") { + if (!("options" in payload.data) || !("selectedOptionIds" in result.data)) return null; + const selected = result.data.selectedOptionIds; + const allowed = new Set(payload.data.options.map(option => option.id)); + if (!Array.isArray(selected) || selected.length === 0 || selected.some(id => !allowed.has(id)) + || selected.length < payload.data.minSelected + || (payload.data.maxSelected != null && selected.length > payload.data.maxSelected)) return null; + const options = new Map(payload.data.options.map(option => [option.id, option])); + approvedOptions = selected.map(id => options.get(id)!); + } + return { outcome: row.status, target: payload.data.target ?? null, commentId: result.data.commentId, approvedOptions } as const; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 2b5fe72ab2b..8c34fb02d54 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -25,6 +25,7 @@ import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js"; import { notifyApprovalWork } from "./approval-work-notifications.js"; import { notifyDecisionTargetWork, notifyDecisionTargetIssueRemoval } from "./decision-targets.js"; import { assertOnboardingFirstTaskCreationAllowed, assertOnboardingFirstTaskDependenciesAllowed } from "./onboarding-first-task-admission.js"; +import { readPersistedConfirmationDecision } from "./confirmation-decision.js"; import { resolveConnectionIntentContinuationSource, resolveItemVerdictContinuationWake } from "./chat-interaction-continuation.js"; import { insertIssueComment } from "./issue-comment-writes.js"; import { measureRunTransaction } from "./execution-transaction-timing.js"; @@ -2421,13 +2422,17 @@ async function findAcceptedPlanDocumentInteraction( .select({ id: issueThreadInteractions.id, payload: issueThreadInteractions.payload, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + result: issueThreadInteractions.result, + resolvedAt: issueThreadInteractions.resolvedAt, }) .from(issueThreadInteractions) .where( and( eq(issueThreadInteractions.companyId, input.companyId), eq(issueThreadInteractions.issueId, input.sourceIssueId), - eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.kind, ["request_confirmation", "request_checkbox_confirmation"]), eq(issueThreadInteractions.status, "accepted"), ), ) @@ -2437,6 +2442,7 @@ async function findAcceptedPlanDocumentInteraction( ); for (const row of rows) { + if (!readPersistedConfirmationDecision(row)) continue; const target = readAcceptedPlanConfirmationTarget( row.payload, input.sourceIssueId, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index acb946cad60..35d895a1183 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -3,7 +3,7 @@ import { DOWNLOAD_ARTIFACT, DOWNLOAD_ARTIFACT_DEFINITION, downloadArtifactSchema import { notifyStatusCardIssueChange } from "../status-card-notifications.js"; import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js"; import { notifyAttentionRetentionWork } from "../retention-work-notifications.js"; -import { requiredQualityReviewSchema } from "@paperclipai/shared"; +import { requiredQualityReviewSchema, type RequestCheckboxConfirmationOption } from "@paperclipai/shared"; import { priorRecoveryApiReceipt } from "../recovery-effect-receipts.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js"; import { caseHandoffService } from "../case-handoffs.js"; @@ -97,6 +97,7 @@ import { approvalService } from "../approvals.js"; import { documentService } from "../documents.js"; import { issueService } from "../issues.js"; import { issueThreadInteractionService } from "../issue-thread-interactions.js"; +import { readPersistedConfirmationDecision } from "../confirmation-decision.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type NativeReviewAssignmentContext } from "./native-review-participant.js"; import { childReviewOutcomes } from "./child-review-outcomes.js"; import { logActivity, persistActivity, publishActivity } from "../activity-log.js"; @@ -2468,6 +2469,7 @@ export class PaperclipRunnerToolAuthority { revisionId: string; revisionNumber: number; markdown: string; + approvedOptions?: RequestCheckboxConfirmationOption[]; } | null> { const acceptedTarget = record( record(record(contextSnapshot).planReviewInteraction).acceptedTargetRevision, @@ -2475,7 +2477,8 @@ export class PaperclipRunnerToolAuthority { let revisionId = typeof acceptedTarget.revisionId === "string" ? acceptedTarget.revisionId : null; - if (!revisionId) revisionId = await this.#latestAcceptedPlanRevisionId(); + const approval = await this.#latestAcceptedPlanApproval(revisionId); + if (!revisionId) revisionId = approval?.revisionId ?? null; if (!revisionId) return null; const [revision] = await this.db.select({ @@ -2496,33 +2499,42 @@ export class PaperclipRunnerToolAuthority { eq(documentRevisions.companyId, this.binding.companyId), )) .limit(1); - if (!revision || !this.binding.museRuntime) return revision ?? null; + if (!revision) return null; + const acceptedRevision = { ...revision, ...(approval?.approvedOptions ? { approvedOptions: approval.approvedOptions } : {}) }; + if (!this.binding.museRuntime) return acceptedRevision; const document = await documentService(this.db).getIssueDocumentByKey(this.binding.issueId, "plan"); const [visible] = await this.#projectMuseDocumentContent(issue, [{ id: revision.revisionId, documentId: revision.documentId, body: revision.markdown, }], document ? [document] : []); - return { ...revision, markdown: visible!.body }; + return { ...acceptedRevision, markdown: visible!.body }; } - async #latestAcceptedPlanRevisionId(): Promise { - const rows = await this.db.select({ payload: issueThreadInteractions.payload }) + async #latestAcceptedPlanApproval(revisionId: string | null): Promise<{ + revisionId: string; + approvedOptions: RequestCheckboxConfirmationOption[] | null; + } | null> { + const rows = await this.db.select({ payload: issueThreadInteractions.payload, kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, result: issueThreadInteractions.result, resolvedAt: issueThreadInteractions.resolvedAt }) .from(issueThreadInteractions) .where(and( eq(issueThreadInteractions.companyId, this.binding.companyId), eq(issueThreadInteractions.issueId, this.binding.issueId), - eq(issueThreadInteractions.kind, "request_confirmation"), + inArray(issueThreadInteractions.kind, ["request_confirmation", "request_checkbox_confirmation"]), eq(issueThreadInteractions.status, "accepted"), + revisionId ? sql`${issueThreadInteractions.payload}->'target'->>'revisionId' = ${revisionId}` : undefined, )) .orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt)); for (const row of rows) { - const target = record(record(row.payload).target); + const decision = readPersistedConfirmationDecision(row); + if (!decision) continue; + const target = decision.target; if ( - target.type === "issue_document" + target?.type === "issue_document" && (target.issueId === undefined || target.issueId === this.binding.issueId) && target.key === "plan" && typeof target.revisionId === "string" && target.revisionId.length > 0 - ) return target.revisionId; + ) return { revisionId: target.revisionId, approvedOptions: decision.approvedOptions }; } return null; } diff --git a/server/src/services/onboarding-first-task-admission.ts b/server/src/services/onboarding-first-task-admission.ts index f032eb261fa..d278a55a4fc 100644 --- a/server/src/services/onboarding-first-task-admission.ts +++ b/server/src/services/onboarding-first-task-admission.ts @@ -1,11 +1,8 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { agents, documents, heartbeatRuns, issueComments, issueDocuments, issues, issueThreadInteractions, type Db } from "@paperclipai/db"; -import { ONBOARDING_FIRST_TASK_ORIGIN_KIND, requestCheckboxConfirmationPayloadSchema, requestCheckboxConfirmationResultSchema, - requestConfirmationPayloadSchema, requestConfirmationResultSchema } from "@paperclipai/shared"; +import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; import { badRequest, forbidden } from "../errors.js"; - -const object = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +import { readPersistedConfirmationDecision } from "./confirmation-decision.js"; const runOriginFields = { nativeIssueId: heartbeatRuns.nativeIssueId, @@ -97,10 +94,7 @@ export async function assertOnboardingFirstTaskDependenciesAllowed(tx: Db, input assertNotBlockedByFirstTaskSource(sources.find(source => source.isRequestedBlocker)); } -/** Shared persisted-decision proof; creation and completion retain different authority. */ -async function readOnboardingFirstTaskDecision(tx: Db, input: { - companyId: string; issueId: string; outcomes: Array<"accepted" | "rejected">; -}): Promise<"accepted" | "rejected" | null> { +async function readLatestProposal(tx: Db, input: { companyId: string; issueId: string }, planOnly = false) { // Select the newest proposal before checking its outcome: a later pending or // rejected proposal must never fall back to an older accepted scope. Governed // tool/credential actions and custom completion reviews are not proposals. @@ -109,23 +103,17 @@ async function readOnboardingFirstTaskDecision(tx: Db, input: { inArray(issueThreadInteractions.kind, ["request_confirmation", "request_checkbox_confirmation"]), sql`NOT (${issueThreadInteractions.payload} ?| ARRAY['toolAction', 'secretProposal', 'connectionAuthorization'])`, sql`COALESCE(${issueThreadInteractions.payload}->'target'->>'type', '') <> 'custom'`, + planOnly ? sql`${issueThreadInteractions.payload}->'target'->>'type' = 'issue_document' + AND ${issueThreadInteractions.payload}->'target'->>'key' = 'plan'` : undefined, )).orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id)).limit(1).for("share"); - if (!proposal || (proposal.status !== "accepted" && proposal.status !== "rejected") || - !input.outcomes.includes(proposal.status) || !proposal.resolvedAt) return null; - const checkbox = proposal.kind === "request_checkbox_confirmation"; - const payload = checkbox ? requestCheckboxConfirmationPayloadSchema.safeParse(proposal.payload) - : requestConfirmationPayloadSchema.safeParse(proposal.payload); - const result = checkbox ? requestCheckboxConfirmationResultSchema.safeParse(proposal.result) - : requestConfirmationResultSchema.safeParse(proposal.result); - if (!payload.success || !result.success || result.data.outcome !== proposal.status) return null; - if (checkbox && proposal.status === "accepted") { - const selected = object(result.data).selectedOptionIds; - if (!("options" in payload.data) || !Array.isArray(selected) || selected.length === 0) return null; - const allowed = new Set(payload.data.options.map(option => option.id)); - if (selected.some(id => typeof id !== "string" || !allowed.has(id)) - || selected.length < payload.data.minSelected - || (payload.data.maxSelected != null && selected.length > payload.data.maxSelected)) return null; - } + return proposal; +} + +async function readHumanDecision(tx: Db, input: { companyId: string; issueId: string }, + proposal: typeof issueThreadInteractions.$inferSelect | undefined) { + if (!proposal) return null; + const decision = readPersistedConfirmationDecision(proposal); + if (!decision) return null; if (proposal.sourceRunId) { const [origin] = await tx.select(runOriginFields).from(heartbeatRuns).where(and( @@ -139,7 +127,7 @@ async function readOnboardingFirstTaskDecision(tx: Db, input: { const directHumanDecision = Boolean(proposal.resolvedByUserId && !proposal.resolvedByAgentId && !proposal.resolvedByRunId); if (!directHumanDecision) { - const commentId = result.data.commentId; + const commentId = decision.commentId; if (!commentId || !proposal.resolvedByAgentId || !proposal.resolvedByRunId) return null; const [comment] = await tx.select().from(issueComments).where(and( eq(issueComments.id, commentId), eq(issueComments.companyId, input.companyId), eq(issueComments.issueId, input.issueId), @@ -155,20 +143,28 @@ async function readOnboardingFirstTaskDecision(tx: Db, input: { if (!resolver || runIssueId(resolver) !== input.issueId || (resolver.responsibleUserId && resolver.responsibleUserId !== comment.authorUserId)) return null; } + return decision; +} + +/** Shared persisted-decision proof; creation and completion retain different authority. */ +async function readOnboardingFirstTaskDecision(tx: Db, input: { + companyId: string; issueId: string; outcomes: Array<"accepted" | "rejected">; +}): Promise<"accepted" | "rejected" | null> { + const decision = await readHumanDecision(tx, input, await readLatestProposal(tx, input)); + if (!decision || !input.outcomes.includes(decision.outcome)) return null; // Refusal permits acknowledgement and closure, never execution. An agent-only // plan edit cannot turn that human refusal into permission to execute. - if (proposal.status === "rejected") return "rejected"; + if (decision.outcome === "rejected") return "rejected"; - const target = payload.data.target; + const planDecision = !decision.target + ? await readHumanDecision(tx, input, await readLatestProposal(tx, input, true)) : null; const [plan] = await tx.select({ documentId: documents.id, revisionId: documents.latestRevisionId }) .from(issueDocuments).innerJoin(documents, eq(documents.id, issueDocuments.documentId)).where(and( eq(issueDocuments.companyId, input.companyId), eq(documents.companyId, input.companyId), eq(issueDocuments.issueId, input.issueId), eq(issueDocuments.key, "plan"), )).for("share", { of: documents }); - // Document writers do not all lock the issue, so hold the document row too. - // A saved plan must be accepted by revision; an untargeted card cannot stand - // in for approval of a different or subsequently edited document. + const target = decision.target ?? (plan && planDecision?.outcome === "accepted" ? planDecision.target : null); if (plan || target) { if (!target || target.type !== "issue_document" || target.key !== "plan" || (target.issueId && target.issueId !== input.issueId) || !plan || plan.revisionId !== target.revisionId