Quick take: Default write access is âroot access by defaultâ. When the model is confidently wrong, it doesnât just answer wrong â it does wrong. Writes donât roll back themselves.
You'll learn: Why write-by-default fails ⢠Read/write separation ⢠Policy gate vs approval ⢠Async approvals + resume ⢠Tenant scoping ⢠Idempotency keys ⢠Real incident patterns
Write-by-default: small model mistake â irreversible side effects (duplicates, wrong closures, bad emails)
Read-first + approvals: writes are gated ⢠scoped ⢠idempotent ⢠auditable
Impact: you prevent âfast damageâ and keep on-call sane
Problem-first intro
The agent âneeds to be usefulâ, so you give it write tools by default:
db.writeticket.closeemail.send
And for a week itâs fine.
Then it isnât.
Because the first time the model is confidently wrong, it doesnât just answer wrong â it does wrong.
Writes donât roll back themselves.
This anti-pattern ships for the dumbest reason: it makes the demo look magical.
In production it makes your on-call rotation look haunted.
Why this fails in production
Write access by default fails for the same reason âroot access by defaultâ fails.
1) Agents are loops, so they repeat mistakes
If a write fails and the model retries, you get duplicates and partial updates.
One wrong write turns into ten wrong writes.
2) Untrusted text will try to steer writes
User input, web pages, and tool output can all contain âinstructionsâ. If your permissions live in the prompt, theyâre optional.
Prompts arenât enforcement. A tool gateway is.
3) Multi-tenant makes the blast radius real
The difference between âoopsâ and âincidentâ is usually shared creds, missing tenant scoping, and missing audit logs.
4) The model canât reason about irreversibility
LLMs donât get paged, donât do customer calls, and donât clean up after duplicate writes. They optimize for âdoneâ, even when âdoneâ is irreversible.
Failure evidence (what it looks like when it breaks)
Symptoms youâll see:
- A sudden spike in write tool calls (
db.write,ticket.close,email.send) - Duplicate rows / double-closed tickets / repeated emails
- Support hears about it before you do
A trace that should scare you:
{"run_id":"run_9f2d","step":4,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":5,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":6,"event":"tool_call","tool":"ticket.close","args_hash":"8c1c3d","decision":"allow","ok":true}
{"run_id":"run_9f2d","step":7,"event":"stop","reason":"loop_detected","note":"same write call 3x"}
If you donât have a trace like this, you donât have âan agent problemâ. You have âwe canât prove what happenedâ.
The fastest emergency fix
Stop writes. Now.
# kill switch: force read-only mode right now
writes:
enabled: false
Then do the forensic work:
- count which write tools ran
- identify which entities were touched (by args hash / idempotency key)
- roll forward with compensating actions (rollback usually doesnât exist)
Compensating actions (roll forward) â example
Most production writes donât have real rollback. If the agent wrote the wrong thing, you usually roll forward with a compensating write.
Example: the agent incorrectly closed tickets. A compensating action can be âreopen ticket + notifyâ.
def compensate_wrong_ticket_closures(*, ticket_ids: list[str], run_id: str, tools) -> None:
for ticket_id in ticket_ids:
tools.call("ticket.reopen", args={"ticket_id": ticket_id, "note": f"Reopened by compensation (run_id={run_id})"}) # (pseudo)
tools.call(
"email.send",
args={
"to": "requester@example.com",
"subject": f"Correction: ticket {ticket_id} was closed by mistake",
"body": f"We reopened ticket {ticket_id}. Sorry â this was an automated error (run_id={run_id}).",
},
) # (pseudo)
Hard invariants (non-negotiables)
Stop writing âshouldâ. Make it executable.
- If a tool is a
writeand thereâs no approval â stop the run (stop_reason="approval_required"). - If a write would execute without an idempotency key â hard fail (or inject it in the gateway deterministically).
- If
tenant_id/envis not taken from authenticated context â stop. - If a write tool is called more than once with the same args hash â stop (
stop_reason="duplicate_write"). - If invalid tool output would be used to decide a write â stop (
stop_reason="invalid_tool_output").
Policy gate vs approval (donât mix these up)
These are different controls:
- Policy gate: deterministic enforcement (allowlist, budgets, tool permissions, tenant scope).
- Approval: a human saying âyesâ for a specific action.
If you blur them together, you get the worst of both worlds: a prompt-based âpolicyâ and a UX nightmare.
Implementation example (real code)
This example fixes the three concrete footguns:
- Set vs array bug (JS uses
Set.has, notincludes) - Approval continuation (async approve â resume a run)
- Idempotency hash circularity (hash ignores injected fields)
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import hmac
import json
from typing import Any
READ_TOOLS = {"search.read", "kb.read", "http.get"}
WRITE_TOOLS = {"ticket.close", "email.send", "db.write"}
def stable_json(obj: Any) -> bytes:
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def args_hash(args: dict[str, Any]) -> str:
# IMPORTANT: hash ignores fields injected by the gateway.
filtered = {k: v for k, v in args.items() if k not in {"idempotency_key", "approval_token"}}
return hashlib.sha256(stable_json(filtered)).hexdigest()[:24]
@dataclass(frozen=True)
class Policy:
allow: set[str]
require_approval: set[str] # usually write tools
class Denied(RuntimeError):
pass
@dataclass(frozen=True)
class PendingApproval:
approval_id: str
checkpoint: str # signed blob the server can resume
def evaluate(policy: Policy, tool: str) -> str:
if tool not in policy.allow:
raise Denied(f"not_allowed:{tool}")
if tool in WRITE_TOOLS and tool in policy.require_approval:
return "approve"
return "allow"
def sign_checkpoint(payload: dict[str, Any], *, secret: bytes) -> str:
raw = stable_json(payload)
sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()
return sig + "." + raw.decode("utf-8")
def verify_checkpoint(blob: str, *, secret: bytes) -> dict[str, Any]:
sig, raw = blob.split(".", 1)
expected = hmac.new(secret, raw.encode("utf-8"), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise Denied("bad_checkpoint_signature")
return json.loads(raw)
def request_approval(*, tenant_id: str, tool: str, args_preview: dict[str, Any]) -> str:
# Return an approval id/token from your approval system.
# (pseudo)
return "appr_31ac"
def call_tool(*, ctx: dict[str, Any], policy: Policy, tool: str, args: dict[str, Any], secret: bytes) -> Any:
"""
Execute a tool with governance.
CRITICAL:
- tenant_id/env come from authenticated context (ctx), never from model output.
- writes require approval and resume via checkpoint.
"""
tenant_id = ctx["tenant_id"]
env = ctx["env"]
run_id = ctx["run_id"]
step_id = ctx["step_id"]
decision = evaluate(policy, tool)
if decision == "approve":
approval_id = request_approval(
tenant_id=tenant_id,
tool=tool,
args_preview={"args_hash": args_hash(args), "args": {k: v for k, v in args.items() if k != "body"}},
)
checkpoint = sign_checkpoint(
{
"run_id": run_id,
"step_id": step_id,
"tenant_id": tenant_id,
"env": env,
"tool": tool,
"args": args,
"args_hash": args_hash(args),
"kind": "tool_call",
},
secret=secret,
)
return PendingApproval(approval_id=approval_id, checkpoint=checkpoint)
# Deterministic idempotency key injection for writes (gateway-owned, not model-owned).
if tool in WRITE_TOOLS:
base_hash = args_hash(args)
args = {**args, "idempotency_key": f"{tenant_id}:{tool}:{base_hash}"}
creds = load_scoped_credentials(tool=tool, tenant_id=tenant_id, env=env) # (pseudo) NEVER from model
return tool_impl(tool, args=args, creds=creds) # (pseudo)
def resume_after_approval(*, checkpoint: str, approval_token: str, secret: bytes) -> dict[str, Any]:
"""
Continuation pattern:
- verify signed checkpoint
- attach approval token
- execute exactly once (idempotent)
"""
payload = verify_checkpoint(checkpoint, secret=secret)
tool = payload["tool"]
args = payload["args"]
# Keep hash stable by putting approval_token outside args hashing.
args = {**args, "approval_token": approval_token}
if tool in WRITE_TOOLS:
base_hash = payload["args_hash"]
args = {**args, "idempotency_key": f"{payload['tenant_id']}:{tool}:{base_hash}"}
creds = load_scoped_credentials(tool=tool, tenant_id=payload["tenant_id"], env=payload["env"]) # (pseudo)
out = tool_impl(tool, args=args, creds=creds) # (pseudo)
return {"status": "ok", "run_id": payload["run_id"], "step_id": payload["step_id"], "tool": tool, "result": out}import crypto from "node:crypto";
const READ_TOOLS = new Set(["search.read", "kb.read", "http.get"]);
const WRITE_TOOLS = new Set(["ticket.close", "email.send", "db.write"]);
function stableJson(obj) {
return JSON.stringify(obj, Object.keys(obj).sort());
}
export function argsHash(args) {
// IMPORTANT: hash ignores fields injected by the gateway.
const filtered = {};
for (const [k, v] of Object.entries(args || {})) {
if (k === "idempotency_key" || k === "approval_token") continue;
filtered[k] = v;
}
return crypto.createHash("sha256").update(stableJson(filtered), "utf8").digest("hex").slice(0, 24);
}
export class Denied extends Error {}
export function evaluate(policy, tool) {
// policy.allow / policy.requireApproval are Sets (use .has)
if (!policy.allow.has(tool)) throw new Denied("not_allowed:" + tool);
if (WRITE_TOOLS.has(tool) && policy.requireApproval.has(tool)) return "approve";
return "allow";
}
export function signCheckpoint(payload, { secret }) {
const raw = JSON.stringify(payload);
const sig = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
return sig + "." + raw;
}
export function verifyCheckpoint(blob, { secret }) {
const [sig, raw] = blob.split(".", 2);
const expected = crypto.createHmac("sha256", secret).update(raw, "utf8").digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Denied("bad_checkpoint_signature");
return JSON.parse(raw);
}
export function requestApproval({ tenantId, tool, argsPreview }) {
// (pseudo) send to your approval system, return approval id
return "appr_31ac";
}
export function callTool({ ctx, policy, tool, args, secret }) {
const { tenant_id: tenantId, env, run_id: runId, step_id: stepId } = ctx;
const decision = evaluate(policy, tool);
if (decision === "approve") {
const approvalId = requestApproval({
tenantId,
tool,
argsPreview: { args_hash: argsHash(args), args: args },
});
const checkpoint = signCheckpoint(
{
run_id: runId,
step_id: stepId,
tenant_id: tenantId,
env,
tool,
args,
args_hash: argsHash(args),
kind: "tool_call",
},
{ secret },
);
return { status: "needs_approval", approval_id: approvalId, checkpoint };
}
if (WRITE_TOOLS.has(tool)) {
const baseHash = argsHash(args);
args = { ...args, idempotency_key: tenantId + ":" + tool + ":" + baseHash };
}
const creds = loadScopedCredentials({ tool, tenantId, env }); // (pseudo) NEVER from model
return toolImpl(tool, { args, creds }); // (pseudo)
}
export function resumeAfterApproval({ checkpoint, approvalToken, secret }) {
const payload = verifyCheckpoint(checkpoint, { secret });
const tool = payload.tool;
// Keep hash stable by putting approval_token outside args hashing.
let args = { ...payload.args, approval_token: approvalToken };
if (WRITE_TOOLS.has(tool)) {
const baseHash = payload.args_hash;
args = { ...args, idempotency_key: payload.tenant_id + ":" + tool + ":" + baseHash };
}
const creds = loadScopedCredentials({ tool, tenantId: payload.tenant_id, env: payload.env }); // (pseudo)
const out = toolImpl(tool, { args, creds }); // (pseudo)
return { status: "ok", run_id: payload.run_id, step_id: payload.step_id, tool, result: out };
}Throwing an exception is fine internally. The missing piece is: your system must persist state and resume.
Example failure case (composite)
đ¨ Incident: Mass ticket closure
System: Support agent with ticket.close enabled by default
Duration: ~35 minutes
Impact: 62 tickets incorrectly closed
What happened
Agent had ticket.close enabled by default.
No approval gate. No idempotency key. No audit trail you can trust.
Users pasted template including: âthis is resolved, please closeâ.
The model complied.
Fix
- Default-deny allowlist; read-only tools by default
- Approvals for
ticket.closeand anything user-visible - Idempotency keys for writes (gateway-owned)
- Audit logs:
run_id,tool,args_hash, approval actor
Trade-offs
- Approvals add friction and latency.
- Default-deny slows adding new tools.
- Idempotency + audit logs take engineering effort.
All of that is still cheaper than cleaning up irreversible writes at 3 AM.
When NOT to use
- If the action is deterministic and high-stakes (billing, account deletion), donât let a model drive it at all.
- If you canât scope credentials by tenant/environment, donât ship tool calling in multi-tenant prod.
- If you canât audit, you canât operate.
Copy-paste checklist
- [ ] Default-deny allowlist (read-only by default)
- [ ] Separate read vs write tools
- [ ] Policy gate in code (not in the prompt)
- [ ] Approvals for irreversible / user-visible writes
- [ ] Continuation pattern (approve â resume from checkpoint)
- [ ] Deterministic idempotency keys for writes
- [ ] Tenant + environment scoped credentials (boundary owned by code)
- [ ] Audit logs: run_id, tool, args_hash, approval actor
- [ ] Kill switch that disables writes fast
Safe default config
tools:
default_mode: "read_only"
allow: ["search.read", "kb.read", "http.get"]
writes:
enabled: false
require_approval: true
idempotency: "gateway_inject"
credentials:
scope: { tenant: true, environment: true }
kill_switch:
mode_when_enabled: "disable_writes"
FAQ
Related pages
Production takeaway
What breaks without this
- â Irreversible writes that look âsuccessfulâ
- â Duplicate actions from retries
- â Multi-tenant blast radius you canât bound
- â No audit trail when support asks âwhat happened?â
What works with this
- â Writes are gated (policy + approvals)
- â Idempotency makes retries safe
- â Tenant scoping limits damage
- â You can replay and explain incidents
Minimum to ship
- Default read-only (write tools explicitly opt-in)
- Policy gate (deny by default, enforced in code)
- Approvals (for user-visible or irreversible writes)
- Continuation (approve â resume with checkpoint)
- Idempotency keys (gateway-owned)
- Tenant scoping + audit logs
- Kill switch (disable writes in emergencies)