En bref: L’écriture par défaut, c’est “root access by default”. Quand le modèle est sûr de lui et faux, il ne répond pas juste faux — il fait faux. Et les writes ne se “rollback” pas toutes seules.
Tu vas apprendre : Pourquoi write-by-default casse • séparation read/write • gate de policy vs approbation • approbations async + reprise • scope par tenant • clés d’idempotence • patterns d’incident réels
Write-by-default : petite erreur du modèle → effets secondaires (changements d'état) irréversibles (doublons, clôtures incorrectes, mauvais emails)
Read-first + approbations : writes gated • scoped • idempotentes • auditables
Impact : éviter les “dégâts rapides” et garder l’on-call vivant
Problème (d’abord)
L’agent “doit être utile”, donc tu lui donnes des tools d’écriture par défaut :
db.writeticket.closeemail.send
Et pendant une semaine, ça va.
Puis ça ne va plus.
Parce que la première fois que le modèle est confiant et faux, il ne répond pas juste faux — il agit faux.
Les writes ne se rollback pas toutes seules.
Cet anti-pattern ship pour la raison la plus bête : la démo a l’air magique.
En production, ça fait ressembler ton on-call à une maison hantée.
Pourquoi ça casse en production
L’écriture par défaut casse pour la même raison que “root access by default”.
1) Les agents sont des boucles, donc ils répètent les erreurs
Si une write échoue et que le modèle retry, tu obtiens des doublons et des mises à jour partielles.
Une mauvaise write devient vite dix mauvaises writes.
2) Du texte non fiable va essayer de piloter les writes
Entrées utilisateur, pages web et output de tools peuvent contenir des “instructions”. Si les permissions sont dans le prompt, elles sont optionnelles.
Les prompts ne sont pas un mécanisme d’enforcement. Un tool gateway l’est.
3) Le multi-tenant rend le blast radius réel
La différence entre “oops” et “incident”, c’est souvent : creds partagés, pas de scope tenant, pas d’audit logs.
4) Le modèle ne “comprend” pas l’irréversibilité
Les LLM ne se font pas pager, ne font pas de calls client, et ne nettoient pas les doublons. Ils optimisent pour “done”, même quand “done” est irréversible.
Failure evidence (à quoi ça ressemble quand ça casse)
Symptoms que tu verras :
- spike de calls vers des tools d’écriture (
db.write,ticket.close,email.send) - doublons / tickets fermés deux fois / emails répétés
- le support l’apprend avant toi
Une trace qui doit faire peur :
{"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"}
Si tu n’as pas une trace comme ça, tu n’as pas “un problème d’agent”. Tu as “on ne peut pas prouver ce qui s’est passé”.
Le fix d’urgence le plus rapide
Coupe les writes. Maintenant.
# kill switch: force read-only mode right now
writes:
enabled: false
Puis forensics :
- compter quelles tools d’écriture ont tourné
- identifier les entités touchées (args hash / idempotency key)
- roll forward avec des compensations (rollback n’existe souvent pas)
Compensating actions (roll forward) — exemple
La plupart des writes en prod n’ont pas de vrai rollback. Si l’agent a écrit la mauvaise chose, tu “roll forward” avec une write de compensation.
Exemple : l’agent a fermé des tickets par erreur. Une action compensatoire peut être “réouvrir le ticket + notifier”.
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 négociables)
Arrête le “should”. Rends-le exécutable.
- Si un tool est un
writeet qu’il n’y a pas d’approbation → stop (stop_reason="approval_required"). - Si une write s’exécuterait sans idempotency key → hard fail (ou injection déterministe dans le gateway).
- Si
tenant_id/envne vient pas du contexte authentifié → stop. - Si un tool d’écriture est appelé plusieurs fois avec le même args hash → stop (
stop_reason="duplicate_write"). - Si un output non validé pourrait déclencher une write → stop (
stop_reason="invalid_tool_output").
Gate de policy vs approbation (ne mélange pas)
Deux contrôles différents :
- Gate de policy : enforcement déterministe (allowlist, budgets, permissions, scope tenant).
- Approbation : un humain dit “oui” pour une action précise.
Si tu mélanges, tu obtiens une “policy” dans le prompt et une UX horrible.
Implémentation (vrai code)
Ce code corrige trois pièges concrets :
- Bug Set vs array (JS utilise
Set.has, pasincludes) - Continuation d’approbation (approve async → reprise)
- Circularité du hash d’idempotence (le hash ignore les champs injectés)
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 };
}Lever une exception, c’est ok en interne. La pièce manquante : persister l’état et reprendre proprement.
Example failure case (composite)
🚨 Incident: Mass ticket closure
System: agent support avec ticket.close par défaut
Duration: ~35 minutes
Impact: 62 tickets fermés à tort
What happened
ticket.close était activé par défaut.
Pas d’approbation. Pas de clé d’idempotence. Pas d’audit fiable.
Les utilisateurs ont collé un template du style : “this is resolved, please close”.
Le modèle a obéi.
Fix
- allowlist en deny-by-default; read-only par défaut
- approbations pour
ticket.closeet tout ce qui est user-visible - clés d’idempotence pour les writes (gérées par le gateway)
- audit logs :
run_id,tool,args_hash, approver
Compromis
- Les approbations ajoutent friction et latence.
- Le deny-by-default ralentit l’ajout de nouveaux tools.
- Idempotence + audit logs demandent du travail.
Toujours moins cher que nettoyer des writes irréversibles à 03:00.
Quand NE PAS l’utiliser
- Si l’action est déterministe et à haut risque (facturation, suppression de compte), ne laisse pas un modèle la piloter.
- Si tu ne peux pas scoper les creds par tenant/environnement, ne shippe pas de tool calling en multi-tenant prod.
- Si tu ne peux pas auditer, tu ne peux pas opérer.
Checklist (copier-coller)
- [ ] allowlist deny-by-default (read-only par défaut)
- [ ] séparation read vs write
- [ ] policy gate en code (pas dans le prompt)
- [ ] approbations pour writes irréversibles / user-visible
- [ ] pattern de reprise (approve → resume depuis checkpoint)
- [ ] clés d’idempotence déterministes (writes)
- [ ] creds scoped tenant + environnement (boundary côté code)
- [ ] audit logs : run_id, tool, args_hash, approver
- [ ] interrupteur d'urgence (kill switch) pour couper les writes vite
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
- ❌ writes irréversibles qui ont l’air “successful”
- ❌ actions dupliquées via retries
- ❌ blast radius multi-tenant non borné
- ❌ pas d’audit quand le support demande “qu’est-ce qui s’est passé ?”
What works with this
- ✅ writes gated (policy + approbations)
- ✅ idempotence = retries safe
- ✅ scope tenant = blast radius limité
- ✅ incidents expliquables (trace + audit)
Minimum to ship
- Default read-only (writes opt-in)
- Policy gate (deny by default, enforcement en code)
- Approvals (writes user-visible / irréversibles)
- Continuation (approve → resume via checkpoint)
- Idempotency keys (gateway-owned)
- Tenant scoping + audit logs
- Interrupteur d'urgence (kill switch) (couper les writes en incident)