windmill-labs/windmill · warning · PlanWriteRefusedError
PlanWriteRefusedError
Error message
PlanWriteRefusedError
What it means
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.
Source
Thrown at frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts:213
input: UpdateArtifactInput,
opts?: { sessionId?: string; canWritePlan?: () => boolean }
): Promise<PersistedArtifact | undefined> {
let refused = false
const { outcome, artifact } = await mutateArtifact(id, (stored) => {
// Read inside the mutator: hoisted out, it would weigh a stale copy against a fresh one.
const held = this.artifacts.find((a) => a.id === id)
const existing = furtherAlong(stored, held)
if (!existing || (opts?.sessionId !== undefined && existing.sessionId !== opts.sessionId)) {
refused = true
return undefined
}
// A plan mark on *either* candidate is enough: furtherAlong can settle on a copy whose
// role is unset, and rows whose marks disagree are what this guards.
if (
opts?.canWritePlan?.() === false &&
[stored, held].some((a) => a !== undefined && isPlanArtifact(a, a.sessionId))
) {
throw new PlanWriteRefusedError()
}
return reviseInto(existing, input)
})
if (refused) return undefined
if (artifact?.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()
if (artifact) this.#reflect(artifact)
return artifact
}
/**
* Put a proposal into the session's one plan document, creating it the first time.
*
* Both halves inside one transaction, so a second tab proposing at the same moment revises
* the row this one wrote rather than racing it: the id is the session's, and whichever
* transaction runs second reads the first one's result.
*/
async savePlan(
sessionId: string,View on GitHub (pinned to e474e8803c)
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).
Example fix
// before
await store.update(id, planDraft)
// after
if (store.canWritePlan?.() === false) {
return reportPlanWriteRefused(toolCallbacks, toolId)
}
await store.update(id, planDraft) Defensive patterns
Strategy: type-guard
Validate before calling
if (opts?.canWritePlan?.() === false && isPlanArtifact(candidate, candidate.sessionId)) {
// skip the write or propose instead
} Type guard
function canAttemptPlanWrite(store: SessionArtifactsStore, a?: Artifact): boolean {
return store.canWritePlan?.() !== false && (a === undefined || !isPlanArtifact(a, a.sessionId))
} Try / catch
try {
await store.update(id, input)
} catch (e) {
if (e instanceof PlanWriteRefusedError) {
return reportPlanWriteRefused(toolCallbacks, toolId)
}
throw e
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- npm exited with ${exitCode === null ? "a signal" : `code ${e
- Failed to create schedule "${requestBody.path}": ${formatToo
- Failed to create ${triggerConfig.label} "${requestBody.path}
- Can't create script from non-inline script
- no first step found
AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03).
Data as JSON: /api/errors/739f48ef95c3faa4.
Report an issue: GitHub.