windmill-labs/windmill · error · PlanWriteRefusedError

The session plan document may not be written here

Error message

The session plan document may not be written here

What it means

PlanWriteRefusedError is thrown by artifactsState.create when a plan-role artifact write is attempted while the caller-provided canWritePlan() policy returns false. It distinguishes 'the plan is read-only in this posture' from 'no such artifact' (undefined), so callers cannot misread a refusal as a missing document. Tool wrappers report it back to the model as a refused plan write.

Source

Thrown at frontend/src/lib/components/copilot/chat/artifacts/artifactsState.svelte.ts:175

			sessionId,
			chatId: input.chatId,
			kind: input.kind ?? 'md',
			role: input.role,
			approvedVersion: input.approvedVersion,
			name: input.name,
			content: input.content,
			createdAt: now,
			updatedAt: now,
			version: 1
		})
		// A plan's id is the session's, so a second one cannot be minted; the slot check happens
		// on the row this write is about to replace, inside the transaction that replaces it.
		const id = input.role === 'plan' ? planArtifactId(sessionId) : randomUUID()
		const { outcome, artifact } = await mutateArtifact(id, (existing) => {
			// Before the slot check: a posture that may not mint a plan at all is the more useful
			// thing to say, and it holds whether or not the session already has one.
			if (input.role === 'plan' && opts?.canWritePlan?.() === false) {
				throw new PlanWriteRefusedError()
			}
			if (existing) throw new PlanSlotTakenError(existing)
			const created = draft(id)
			return { artifact: created, snapshots: [snapshotOf(created, 1)] }
		})
		// An ordinary artifact degrades unpersisted; a plan cannot. Returning one the database
		// refused would let the user approve a plan that disappears on reload.
		if (input.role === 'plan' && outcome !== 'saved') throw new ArtifactPersistenceError()
		const written = artifact ?? draft(id)
		this.#reflect(written)
		return written
	}

	/**
	 * Merge changes into an existing artifact. Returns undefined if `id` is unknown, or if
	 * `opts.sessionId` is given and the artifact belongs to a different session.
	 */
	async update(

View on GitHub (pinned to e474e8803c)

Solutions

  1. Check canWritePlan() (or the session's plan posture) before issuing the plan-create tool call
  2. If a plan already exists and is locked, revise the existing plan artifact (update) instead of creating a new one
  3. Move the session back to a writable planning posture if the workflow intends a new plan

Example fix

// before
await artifacts.create({ role: 'plan', sessionId, content }) // throws if refused
// after
if (artifacts.canWritePlan()) await artifacts.create({ role: 'plan', sessionId, content })
else await artifacts.update(planArtifactId(sessionId), { content })
Defensive patterns

Strategy: type-guard

Validate before calling

if (!canWritePlan()) {
  throw new Error('Plan writes are not permitted in the current posture')
}

Type guard

function isPlanWriteRefused(e: unknown): e is PlanWriteRefusedError {
  return e instanceof PlanWriteRefusedError
}

Try / catch

try {
  await artifacts.create({ role: 'plan', sessionId, content })
} catch (e) {
  if (e instanceof PlanWriteRefusedError) {
    toolCallbacks.report('Plan is read-only here; revise the existing plan instead')
    return
  }
  throw e
}

Prevention

When it happens

Trigger: Calling create({ role: 'plan', sessionId, ... }) when opts.canWritePlan() is false — e.g. the plan was already approved/locked, the chat is in a read-only review posture, or plan writing is disabled for the current tool context.

Common situations: Model trying to mint a second plan after approval; a tool invoking plan creation outside the phase where plan writes are permitted; replaying an old plan-create tool call against a session that has moved past the planning stage.

Related errors


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