From 58cdc6441ef671a851fbb0e6eaa93bba3b839e6d Mon Sep 17 00:00:00 2001 From: JulioMCruz Date: Wed, 22 Jul 2026 13:06:49 -0400 Subject: [PATCH] Bind plan mutations to one PM run --- src/tools/createDoc.ts | 9 +++++++++ src/tools/createTask.ts | 7 +++++-- src/tools/planningRun.ts | 26 ++++++++++++++++++++++++++ src/tools/proposePlan.ts | 21 +++++++++++++++++++++ src/tools/upsertPlanGroup.ts | 9 ++++++++- src/tools/upsertPlanTask.ts | 11 ++++++++--- tests/plan-tools.test.ts | 8 ++++++++ tests/proposal-policy.test.ts | 18 ++++++++++++++++++ 8 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/tools/planningRun.ts diff --git a/src/tools/createDoc.ts b/src/tools/createDoc.ts index 8b7d56b..335c42b 100644 --- a/src/tools/createDoc.ts +++ b/src/tools/createDoc.ts @@ -19,6 +19,7 @@ import { projectIdSchema, } from "./docShared.js"; import type { Tool } from "./types.js"; +import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js"; const InputSchema = z .object({ @@ -26,6 +27,7 @@ const InputSchema = z type: docTypeSchema, title: z.string().min(1).max(200), parentId: docIdSchema.optional(), + planningRunId: planningRunIdSchema, }) .strict(); @@ -55,6 +57,13 @@ export const createDoc: Tool = { // create a task-specific spec, but returning the approved plan keeps every // contribution in the shared workspace and makes retries idempotent. const projectData = project.data() as Record; + const runDecision = planningRunDecision( + (projectData.workflow ?? null) as Record | null, + args.planningRunId, + ); + if (runDecision !== "allow" && args.type === "plan") { + return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) }; + } const workflow = (projectData.workflow ?? null) as | { phase?: string; planId?: string } | null; diff --git a/src/tools/createTask.ts b/src/tools/createTask.ts index c0b7f2f..dc9a6b9 100644 --- a/src/tools/createTask.ts +++ b/src/tools/createTask.ts @@ -75,12 +75,15 @@ export const createTask: Tool = { }; } const workflow = project.data()?.workflow as { phase?: string } | undefined; - if (workflow?.phase === "planning" || workflow?.phase === "awaiting_approval") { + if ( + workflow?.phase && + ["planning", "awaiting_approval", "approved", "running", "pm_review", "complete"].includes(workflow.phase) + ) { return { ok: false, errorClass: "FORBIDDEN", message: - "This project plan has not been approved. Add planTask blocks and call proposePlan; createTask is enabled after user approval.", + "This project uses an approval-controlled plan. Draft with planTask blocks during planning; after approval the server materializes the immutable task set. Do not call createTask directly.", }; } diff --git a/src/tools/planningRun.ts b/src/tools/planningRun.ts new file mode 100644 index 0000000..7bb5ab2 --- /dev/null +++ b/src/tools/planningRun.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const planningRunIdSchema = z.string().uuid().optional(); + +export type PlanningRunDecision = "allow" | "missing" | "stale"; + +/** A planning token binds every draft mutation to the single PM turn that + * atomically claimed the project. Legacy workflows without a token remain + * usable while deployments roll forward. */ +export function planningRunDecision( + workflow: Record | null | undefined, + supplied: string | undefined, +): PlanningRunDecision { + if (String(workflow?.phase ?? "") !== "planning") return "allow"; + const expected = + typeof workflow?.planningRunId === "string" ? workflow.planningRunId : ""; + if (!expected) return "allow"; + if (!supplied) return "missing"; + return supplied === expected ? "allow" : "stale"; +} + +export function planningRunError(decision: Exclude): string { + return decision === "missing" + ? "This planning run requires its planningRunId. Use the token from the PM assignment." + : "This planning turn is stale. Stop without changing the plan; a newer PM turn owns it."; +} diff --git a/src/tools/proposePlan.ts b/src/tools/proposePlan.ts index 4d8beda..0dd68b8 100644 --- a/src/tools/proposePlan.ts +++ b/src/tools/proposePlan.ts @@ -16,11 +16,14 @@ import { docIdSchema, ensureDoc, projectIdSchema } from "./docShared.js"; import { logActivity } from "../activityEvents.js"; import { postProjectChat } from "../projectChat.js"; import type { Tool } from "./types.js"; +import { db } from "../firestore.js"; +import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js"; const InputSchema = z .object({ projectId: projectIdSchema, docId: docIdSchema.optional(), + planningRunId: planningRunIdSchema, }) .strict(); @@ -50,6 +53,22 @@ export const proposePlan: Tool = { "Mark a plan doc as proposed (ready for human approval) and notify the team in that doc's discussion. Call this once you've laid out the task groups and draft tasks. The plan only becomes real board tasks after a human approves it in the app. Targets the active plan doc unless docId is given.", input: InputSchema, async run({ args, ctx }) { + const initialProject = await db() + .collection("wallets") + .doc(ctx.wallet) + .collection("projects") + .doc(args.projectId) + .get(); + if (!initialProject.exists) { + return { ok: false, errorClass: "NOT_FOUND", message: `No project "${args.projectId}" for this wallet.` }; + } + const initialDecision = planningRunDecision( + (initialProject.data()?.workflow ?? null) as Record | null, + args.planningRunId, + ); + if (initialDecision !== "allow") { + return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(initialDecision) }; + } const refs = await ensureDoc(ctx.wallet, args.projectId, { docId: args.docId, }); @@ -99,6 +118,8 @@ export const proposePlan: Tool = { if (!freshProject.exists || !freshDoc.exists) throw new Error("plan not found"); const projectData = freshProject.data() as Record; const workflow = (projectData.workflow ?? {}) as Record; + const runDecision = planningRunDecision(workflow, args.planningRunId); + if (runDecision !== "allow") throw new Error(planningRunError(runDecision)); workflowConvId = typeof workflow.convId === "string" ? workflow.convId diff --git a/src/tools/upsertPlanGroup.ts b/src/tools/upsertPlanGroup.ts index d4aac4c..ba9c744 100644 --- a/src/tools/upsertPlanGroup.ts +++ b/src/tools/upsertPlanGroup.ts @@ -21,6 +21,7 @@ import { touchDocForEdit, } from "./docShared.js"; import type { Tool } from "./types.js"; +import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js"; const InputSchema = z .object({ @@ -30,6 +31,7 @@ const InputSchema = z groupId: blockIdSchema.optional(), title: z.string().min(1).max(200), order: z.number().int().min(0).max(100000).optional(), + planningRunId: planningRunIdSchema, }) .strict(); @@ -63,7 +65,12 @@ export const upsertPlanGroup: Tool = { } const project = await docRef.parent.parent!.get(); - const phase = String((project.data()?.workflow as { phase?: unknown } | undefined)?.phase ?? ""); + const workflow = (project.data()?.workflow ?? null) as Record | null; + const runDecision = planningRunDecision(workflow, args.planningRunId); + if (runDecision !== "allow") { + return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) }; + } + const phase = String(workflow?.phase ?? ""); if (phase === "awaiting_approval") { return { ok: false, diff --git a/src/tools/upsertPlanTask.ts b/src/tools/upsertPlanTask.ts index c850bef..d877cfc 100644 --- a/src/tools/upsertPlanTask.ts +++ b/src/tools/upsertPlanTask.ts @@ -22,6 +22,7 @@ import { touchDocForEdit, } from "./docShared.js"; import type { Tool } from "./types.js"; +import { planningRunDecision, planningRunError, planningRunIdSchema } from "./planningRun.js"; const InputSchema = z .object({ @@ -38,6 +39,7 @@ const InputSchema = z acceptance: z.string().max(2000).optional(), /** Other planTask block ids this depends on. */ deps: z.array(blockIdSchema).max(50).optional(), + planningRunId: planningRunIdSchema, }) .strict(); @@ -71,9 +73,12 @@ export const upsertPlanTask: Tool = { } const projectSnapshot = await docRef.parent.parent!.get(); - const phase = String( - (projectSnapshot.data()?.workflow as { phase?: unknown } | undefined)?.phase ?? "", - ); + const workflow = (projectSnapshot.data()?.workflow ?? null) as Record | null; + const runDecision = planningRunDecision(workflow, args.planningRunId); + if (runDecision !== "allow") { + return { ok: false, errorClass: "FORBIDDEN", message: planningRunError(runDecision) }; + } + const phase = String(workflow?.phase ?? ""); if (phase === "awaiting_approval") { return { ok: false, diff --git a/tests/plan-tools.test.ts b/tests/plan-tools.test.ts index 9995d6e..48c849e 100644 --- a/tests/plan-tools.test.ts +++ b/tests/plan-tools.test.ts @@ -71,6 +71,14 @@ describe("plan-doc input schemas", () => { ).toBe(false); }); + it("accepts a planningRunId on every PM plan mutation", () => { + const planningRunId = "b4ed66d0-c1ef-4ae1-9524-54dca0bb571b"; + expect(findTool("createDoc")!.input.safeParse({ projectId: "p1", type: "plan", title: "Plan", planningRunId }).success).toBe(true); + expect(findTool("upsertPlanGroup")!.input.safeParse({ projectId: "p1", title: "Sprint", planningRunId }).success).toBe(true); + expect(findTool("upsertPlanTask")!.input.safeParse({ projectId: "p1", groupId: "g1", title: "Work", planningRunId }).success).toBe(true); + expect(findTool("proposePlan")!.input.safeParse({ projectId: "p1", planningRunId }).success).toBe(true); + }); + it("rejects malformed projectId", () => { const t = findTool("readDoc")!; expect(t.input.safeParse({ projectId: "bad id!" }).success).toBe(false); diff --git a/tests/proposal-policy.test.ts b/tests/proposal-policy.test.ts index d6ce0f7..e4f0deb 100644 --- a/tests/proposal-policy.test.ts +++ b/tests/proposal-policy.test.ts @@ -7,6 +7,7 @@ process.env.JWT_SHARED_SECRET = "a".repeat(40); const { planProposalDecision } = await import("../src/tools/proposePlan.js"); const { normalizedPlanLabel } = await import("../src/tools/docShared.js"); +const { planningRunDecision } = await import("../src/tools/planningRun.js"); describe("plan proposal policy", () => { it("makes a repeated proposal for the same pending revision idempotent", () => { @@ -32,6 +33,23 @@ describe("plan proposal policy", () => { }); }); +describe("planning run ownership", () => { + const workflow = { + phase: "planning", + planningRunId: "b4ed66d0-c1ef-4ae1-9524-54dca0bb571b", + }; + + it("requires and validates the token while planning", () => { + expect(planningRunDecision(workflow, undefined)).toBe("missing"); + expect(planningRunDecision(workflow, "7f9eca90-4f27-49b2-8d40-9dcf81883984")).toBe("stale"); + expect(planningRunDecision(workflow, workflow.planningRunId)).toBe("allow"); + }); + + it("does not affect non-planning document work", () => { + expect(planningRunDecision({ phase: "running", planningRunId: workflow.planningRunId }, undefined)).toBe("allow"); + }); +}); + describe("normalizedPlanLabel", () => { it("collapses cosmetic differences used by retrying PM calls", () => { expect(normalizedPlanLabel(" Final Synthesis ")).toBe("final synthesis");