Агенти з правом запису за замовчуванням (антипатерн) + фікси + код

  • Побач пастку до того, як вона потрапить у прод.
  • Дізнайся, що ламається при впевненій помилці моделі.
  • Скопіюй безпечні defaults: permissions, budgets, idempotency.
  • Знай, коли агент взагалі не потрібен.
Сигнали виявлення
  • Tool calls на run зростають (або повторюються з args hash).
  • Витрати/токени ростуть без кращих результатів.
  • Retries стають постійними (429/5xx).
Write-доступ за замовчуванням перетворює агента на генератор інцидентів. Як це шипиться, що ламає, і як виглядає безпечніший дизайн permission + approval, який реально оперувати.
На цій сторінці
  1. Проблема (з реального продакшену)
  2. Чому це ламається в продакшені
  3. 1) Агент — це цикл, тому він повторює помилки
  4. 2) Ненадійний текст намагається керувати writes
  5. 3) Multi-tenant робить blast radius реальним
  6. 4) Модель не може міркувати про незворотність
  7. Failure evidence (як це виглядає, коли ламається)
  8. Найшвидший emergency fix
  9. Compensating actions (roll forward) — приклад
  10. Жорсткі інваріанти (не обговорюється)
  11. Policy gate vs approval (не плутай)
  12. Приклад реалізації (реальний код)
  13. Приклад інциденту (composite)
  14. 🚨 Інцидент: масове закриття тікетів
  15. Компроміси
  16. Коли НЕ варто
  17. Чекліст (можна копіювати)
  18. Безпечний дефолтний конфіг
  19. FAQ
  20. Пов’язані сторінки
  21. Production takeaway
  22. Що ламається без цього
  23. Що працює з цим
  24. Мінімум, щоб шипнути
Коротко

Коротко: Write-доступ за замовчуванням — це “root access by default”. Коли модель впевнено помиляється, вона не просто відповідає неправильно — вона робить неправильно. Writes не відкочуються самі.

Ти дізнаєшся: Чому write-by-default ламається • Розділення read/write • Policy gate vs approval • Async approvals + resume • Tenant scoping • Idempotency keys • Реальні патерни інцидентів

Concrete metric

Write-by-default: маленька помилка моделі → незворотні побічні ефекти (зміни стану) (дублікати, неправильні closures, неправильні листи)
Read-first + approvals: writes gated • scoped • idempotent • auditable
Ефект: ти зупиняєш “швидку шкоду” і тримаєш on-call у здоровому глузді


Проблема (з реального продакшену)

Агент “має бути корисним”, тому ти даєш йому write-інструменти за замовчуванням:

  • db.write
  • ticket.close
  • email.send

І тиждень усе ок.

Потім — ні.

Бо перший раз, коли модель впевнено помиляється, вона не просто відповідає неправильно — вона робить неправильно.

Truth

Writes не відкочуються самі.

Цей антипатерн шипиться з найтупішої причини: демка виглядає магічно.
У проді це робить твою on-call ротацію схожою на хорор.


Чому це ламається в продакшені

Write-доступ за замовчуванням ламається з тієї ж причини, що й “root access by default”.

Failure analysis

1) Агент — це цикл, тому він повторює помилки

Якщо write не пройшов і модель повторює, ти отримаєш дублікати та часткові апдейти.

Truth

Один неправильний write перетворюється на десять неправильних writes.

2) Ненадійний текст намагається керувати writes

Input юзера, веб-сторінки та output інструментів можуть містити “інструкції”. Якщо permissions живуть у промпті — вони опціональні.

Промпти — не enforcement. Tool gateway — так.

3) Multi-tenant робить blast radius реальним

Різниця між “ой” та “інцидент” зазвичай: shared creds, відсутній tenant scoping та відсутні audit logs.

4) Модель не може міркувати про незворотність

LLM не отримує пейджер, не робить дзвінки клієнтам і не прибирає дублікати. Вона оптимізує під “done”, навіть коли “done” — незворотне.


Failure evidence (як це виглядає, коли ламається)

Симптоми, які ти побачиш:

  • Раптовий спайк write tool calls (db.write, ticket.close, email.send)
  • Дублікати рядків / подвійні closures / повторні листи
  • Support дізнається раніше за тебе

Trace, який має лякати:

JSON
{"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"}

Якщо в тебе немає такого trace, у тебе не “проблема з агентом”. У тебе “ми не можемо довести, що сталося”.

Найшвидший emergency fix

Зупини writes. Зараз.

YAML
# kill switch: force read-only mode right now
writes:
  enabled: false

Далі — форензика:

  • порахувати, які write tools запускались
  • ідентифікувати, які сутності зачепили (за args hash / idempotency key)
  • roll forward через compensating actions (rollback зазвичай не існує)

Compensating actions (roll forward) — приклад

У більшості production writes немає справжнього rollback. Якщо агент написав неправильне, зазвичай ти roll forward компенсуючим write.

Приклад: агент неправильно закрив тікети. Компенсація може бути “reopen ticket + notify”.

PYTHON
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)

Жорсткі інваріанти (не обговорюється)

Припини писати “should”. Зроби це executable.

  • Якщо tool — write і немає approval → stop run (stop_reason="approval_required").
  • Якщо write виконався б без idempotency key → hard fail (або детерміновано інжектнути в gateway).
  • Якщо tenant_id / env не взяті з authenticated context → stop.
  • Якщо write tool викликається більше ніж один раз з тим самим args hash → stop (stop_reason="duplicate_write").
  • Якщо невалідний tool output використовується для рішення про write → stop (stop_reason="invalid_tool_output").

Policy gate vs approval (не плутай)

Це різні контроли:

  • Policy gate: детермінований enforcement (allowlist, budgets, permissions, tenant scope).
  • Approval: людина каже “так” на конкретну дію.

Якщо змішати — отримаєш найгірше: prompt-based “policy” і UX-кошмар.

Diagram
Production control flow (read vs write)

Приклад реалізації (реальний код)

Цей приклад фіксить три конкретні footguns:

  1. Set vs array bug (JS використовує Set.has, а не includes)
  2. Approval continuation (async approve → resume run)
  3. Idempotency hash circularity (hash ігнорує інжектнуті поля)
PYTHON
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}
JAVASCRIPT
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 };
}
Insight

Кинути exception — нормально внутрішньо. Missing piece: система має зберігати стан і вміти resume.


Приклад інциденту (composite)

Incident

🚨 Інцидент: масове закриття тікетів

System: support-агент з ticket.close увімкненим за замовчуванням
Duration: ~35 хвилин
Impact: 62 тікети закрито помилково


Що сталося

У агента був ticket.close увімкнений за замовчуванням.
Без approval gate. Без idempotency key. Без audit trail, якому можна довіряти.

Юзери вставили template з фразою: “this is resolved, please close”.

Модель виконала.


Fix

  1. Default-deny allowlist; read-only tools за замовчуванням
  2. Approvals для ticket.close і всього user-visible
  3. Idempotency keys для writes (gateway-owned)
  4. Audit logs: run_id, tool, args_hash, approval actor

Компроміси

Trade-offs
  • Approvals додають friction і latency.
  • Default-deny уповільнює додавання нових tools.
  • Idempotency + audit logs — це інженерна робота.

Все це дешевше, ніж прибирати незворотні writes о 3-й ночі.


Коли НЕ варто

Don’t
  • Якщо дія детермінована й high-stakes (billing, видалення акаунту) — не давай моделі це робити взагалі.
  • Якщо не можеш зробити credential scoping по tenant/environment — не шип tool calling у multi-tenant проді.
  • Якщо не можеш аудити — не можеш оперувати.

Чекліст (можна копіювати)

Production checklist
  • [ ] Default-deny allowlist (read-only за замовчуванням)
  • [ ] Розділити read vs write tools
  • [ ] Policy gate у коді (не в промпті)
  • [ ] Approvals для незворотних / user-visible writes
  • [ ] Continuation pattern (approve → resume з checkpoint)
  • [ ] Детерміновані idempotency keys для writes
  • [ ] Credentials зі scope tenant + environment (boundary owned by code)
  • [ ] Audit logs: run_id, tool, args_hash, approval actor
  • [ ] Kill switch, який швидко вимикає writes

Безпечний дефолтний конфіг

YAML
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

FAQ
Може просто сказати моделі ніколи не писати?
Сказати можна. Enforce — ні. Enforce writes у tool gateway.
Які writes мають вимагати approval?
Будь-що незворотне або user-visible: emails, закриття тікетів, billing, видалення записів.
Idempotency keys важливі, якщо є approvals?
Так. Approvals блокують поганий намір. Idempotency запобігає дублям і retry storms.
Як робити resume після approval?
Зберігай підписаний checkpoint (tool + args + run/step ids), чекай approval, потім resume рівно один раз з idempotency key.

Пов’язані сторінки

Related

Production takeaway

Production takeaway

Що ламається без цього

  • ❌ Незворотні writes, які виглядають “successful”
  • ❌ Дублікати дій через retries
  • ❌ Multi-tenant blast radius без boundaries
  • ❌ Нема audit trail, коли support питає “що сталося?”

Що працює з цим

  • ✅ Writes gated (policy + approvals)
  • ✅ Idempotency робить retries safe
  • ✅ Tenant scoping обмежує шкоду
  • ✅ Можна replay і пояснювати інциденти

Мінімум, щоб шипнути

  1. Default read-only (write tools — явний opt-in)
  2. Policy gate (deny by default, enforcement у коді)
  3. Approvals (для user-visible або незворотних writes)
  4. Continuation (approve → resume з checkpoint)
  5. Idempotency keys (gateway-owned)
  6. Tenant scoping + audit logs
  7. Kill switch (вимкнути writes в emergency)

Не впевнені, що це ваш кейс?

Спроєктувати агента →
⏱️ 11 хв читанняОновлено Бер, 2026Складність: ★★★
Реалізувати в OnceOnly
Safe defaults for tool permissions + write gating.
Використати в OnceOnly
# onceonly guardrails (concept)
version: 1
tools:
  default_mode: read_only
  allowlist:
    - search.read
    - kb.read
    - http.get
writes:
  enabled: false
  require_approval: true
  idempotency: true
controls:
  kill_switch: { enabled: true, mode: disable_writes }
audit:
  enabled: true
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.
Автор

Цю документацію курують і підтримують інженери, які запускають AI-агентів у продакшені.

Контент створено з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Патерни та рекомендації базуються на постмортемах, режимах відмов і операційних інцидентах у розгорнутих системах, зокрема під час розробки та експлуатації governance-інфраструктури для агентів у OnceOnly.