{"record":{"id":"739f48ef95c3faa4","repo":"windmill-labs/windmill","slug":"planwriterefusederror","errorCode":null,"errorMessage":"PlanWriteRefusedError","messagePattern":"PlanWriteRefusedError","errorType":"exception","errorClass":"PlanWriteRefusedError","httpStatus":null,"severity":"warning","filePath":"frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts","lineNumber":213,"sourceCode":"\t\tinput: UpdateArtifactInput,\n\t\topts?: { sessionId?: string; canWritePlan?: () => boolean }\n\t): Promise<PersistedArtifact | undefined> {\n\t\tlet refused = false\n\t\tconst { outcome, artifact } = await mutateArtifact(id, (stored) => {\n\t\t\t// Read inside the mutator: hoisted out, it would weigh a stale copy against a fresh one.\n\t\t\tconst held = this.artifacts.find((a) => a.id === id)\n\t\t\tconst existing = furtherAlong(stored, held)\n\t\t\tif (!existing || (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId)) {\n\t\t\t\trefused = true\n\t\t\t\treturn undefined\n\t\t\t}\n\t\t\t// A plan mark on *either* candidate is enough: furtherAlong can settle on a copy whose\n\t\t\t// role is unset, and rows whose marks disagree are what this guards.\n\t\t\tif (\n\t\t\t\topts?.canWritePlan?.() === false &&\n\t\t\t\t[stored, held].some((a) => a !== undefined && isPlanArtifact(a, a.sessionId))\n\t\t\t) {\n\t\t\t\tthrow new PlanWriteRefusedError()\n\t\t\t}\n\t\t\treturn reviseInto(existing, input)\n\t\t})\n\t\tif (refused) return undefined\n\t\tif (artifact?.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()\n\t\tif (artifact) this.#reflect(artifact)\n\t\treturn artifact\n\t}\n\n\t/**\n\t * Put a proposal into the session's one plan document, creating it the first time.\n\t *\n\t * Both halves inside one transaction, so a second tab proposing at the same moment revises\n\t * the row this one wrote rather than racing it: the id is the session's, and whichever\n\t * transaction runs second reads the first one's result.\n\t */\n\tasync savePlan(\n\t\tsessionId: string,","sourceCodeStart":195,"sourceCodeEnd":231,"githubUrl":"https://github.com/windmill-labs/windmill/blob/e474e8803ce2ff5c2df09a58dab51d45f5c922ca/frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts#L195-L231","documentation":"PlanWriteRefusedError is thrown by SessionArtifactsStore.update when the AI session's canWritePlan guard returns false and the write would modify a plan artifact (stored or held). Windmill uses it to enforce the permission model where plan documents are read-only for the agent unless the user explicitly grants plan-write access. It signals a permission refusal, not data corruption.","triggerScenarios":"Calling store.update(...) with input that targets an artifact whose isPlanArtifact(a, a.sessionId) is true while opts.canWritePlan() === false. Also fires when furtherAlong settled on a copy whose role is unset but the stored or held candidate is a plan artifact.","commonSituations":"An agent/tool without plan-write permission attempts to revise the user's plan document; a session configured read-only for plans gets a plan edit request from the model; a UI flow disabled plan writes but a stale tool call still tries to save.","solutions":["Check canWritePlan() before invoking update; if false, ask the user to enable plan writes or present the change as a proposal instead.","Route the change through the plan proposal API (savePlan/proposal flow) rather than direct artifact update.","Catch PlanWriteRefusedError in the tool layer and report the refusal to the model (artifactTools already maps it via reportPlanWriteRefused)."],"exampleFix":"// before\nawait store.update(id, planDraft)\n// after\nif (store.canWritePlan?.() === false) {\n  return reportPlanWriteRefused(toolCallbacks, toolId)\n}\nawait store.update(id, planDraft)","handlingStrategy":"type-guard","validationCode":"if (opts?.canWritePlan?.() === false && isPlanArtifact(candidate, candidate.sessionId)) {\n  // skip the write or propose instead\n}","typeGuard":"function canAttemptPlanWrite(store: SessionArtifactsStore, a?: Artifact): boolean {\n  return store.canWritePlan?.() !== false && (a === undefined || !isPlanArtifact(a, a.sessionId))\n}","tryCatchPattern":"try {\n  await store.update(id, input)\n} catch (e) {\n  if (e instanceof PlanWriteRefusedError) {\n    return reportPlanWriteRefused(toolCallbacks, toolId)\n  }\n  throw e\n}","preventionTips":["Always gate plan writes behind the canWritePlan capability check.","Prefer the proposal flow over direct plan mutation when permissions are uncertain.","Map PlanWriteRefusedError to a friendly tool result in every tool handler."],"tags":["permissions","plan-artifact","state-management","svelte"],"backgroundTag":"permission-denied-write","analyzedSha":"e474e8803ce2ff5c2df09a58dab51d45f5c922ca","analyzedAt":"2026-09-03T12:38:19.024Z","contentChangedAt":"2026-09-03T12:38:19.024Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}