Permission mode
Each chat conversation carries a permission mode, Claude-Code-style, that decides how a non-destructive change a model proposes reaches effect. The mode governs the mutate tool branch only: reads always run and destructive tools always require a human approval, regardless of the mode. The default for a new conversation is approve (safe-by-default).
The two modes
Section titled “The two modes”approve: amutatetool surfaces a confirm card the operator approves viaapplyTool. This is the existing propose to apply flow.auto: amutatetool is applied immediately, server-side, with no human click. The apply runs through the same propose to apply service the human path uses (same authorization re-check, same audit rows), soautois faster, never weaker.
The three gating tiers
Section titled “The three gating tiers”Gating keys on the tool’s effect (read | mutate | destructive):
readalways auto-runs, in both modes. Reads are never gated; the mode does not affect them.mutateinherits the mode. Inautoit auto-applies server-side; inapproveit surfaces a confirm card.destructivealways requires the humanapplyTool, in both modes. The mode is never consulted for destructive tools.
The decision is a single pure function so it is exhaustively testable:
export function decideToolDisposition({ effect, mode,}: { effect: AiToolEffect; mode: AiPermissionMode;}): "auto-run" | "auto-apply" | "propose" { if (effect === "read") return "auto-run"; // tier 1: never gated if (effect === "destructive") return "propose"; // tier 3: always human return mode === "auto" ? "auto-apply" : "propose"; // tier 2: mode-governed}Security invariant: destructive can never auto-apply
Section titled “Security invariant: destructive can never auto-apply”The mode has no parameter into the destructive apply path. It is consulted only on the mutate branch, so there is no (effect, mode) pair that routes a destructive tool to auto-apply. A test asserts that no permission-mode value changes a destructive tool’s requirement for a human applyTool call, and that auto-apply is reachable only via (mutate, auto).
Setting a conversation to auto only ever skips the human click for non-destructive mutate tools. Deletes and other irreversible actions still require explicit confirmation.
Where the mode lives
Section titled “Where the mode lives”The mode is a durable, per-conversation permission_mode column on ai_conversations (shared Postgres), defaulting to approve. It is read from the conversation row at the start of each turn, so a turn handled on any pod reads the same mode. The conversation row is the single writer; the chat header toggle is a view of it, persisted via updateConversation (owner-scoped, mirroring how model and title are handled). See the propose and apply page for the apply path the auto mode reuses.