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

  1. Check canWritePlan() before invoking update; if false, ask the user to enable plan writes or present the change as a proposal instead.
  2. Route the change through the plan proposal API (savePlan/proposal flow) rather than direct artifact update.
  3. 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

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


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/739f48ef95c3faa4. Report an issue: GitHub.