OrmAI

Guide

Write operations with approval gates

How to let an agent mutate state without losing your nerve. Reason-required writes, two-person approvals, dry runs, and rollback by design.

Dipankar Sarkar · ·Updated April 15, 2026 writesmutationsapprovalshuman-in-the-loop

Letting an agent write to your database is the moment most teams freeze. This guide is the playbook we use when the answer to “should the agent be able to write?” is “yes, but only if…”

The decision matrix

Before policy, decide which mutations the agent can perform autonomously and which need a human. We recommend categorizing every model into one of four classes:

  • No-write. The agent should never mutate this. (Audit logs, billing ledger, immutable history.)
  • Reason-required. The agent can write, but every call must include a justification string that ends up in the audit log.
  • Approval-required. The agent’s call enters a queue; a human approves before it executes.
  • Bounded-burst. The agent can write up to N rows / N times per window without approval; beyond that, escalation.

Decide once, encode in policy, never argue about it again.

Reason-required writes

policy = ...enable_writes(
    models=["Order", "Note"],
    require_reason=True,
)

The agent’s call:

{"name": "db.create", "arguments": {
    "model": "Order",
    "data": {"customer_id": 42, "total_cents": 5000, "status": "pending"},
    "reason": "User requested order placement during chat session 7c2d.",
}}

The reason is required, free-form, and audited. Tell your model in the system prompt: “When calling write tools, include a one-sentence reason describing the user intent.” Models comply with this consistently if you give them the field.

The reason isn’t authorization — anyone can write any string. Its purpose is explanation under audit. When you investigate a six-week-old write, the reason is what tells you why.

Per-field write rules

Sometimes the agent can update some fields on a model but not others. For example: it can set Order.status but not Order.total_cents.

.enable_writes(
    models=["Order"],
    require_reason=True,
    writable_fields={"Order": ["status", "notes"]},
)

Any update to a non-writable field is denied at compile time. The audit log records the attempt.

Approval gates

For genuinely high-stakes mutations, the call enters a pending state until a human approves.

.require_approval(
    tool="db.update",
    model="Subscription",
    when=lambda call: call.data.get("plan") in {"enterprise", "platinum"},
)
.require_approval(
    tool="db.update",
    model="Order",
    when=lambda call: call.affected_rows > 50,
)

When the condition matches, OrmAI returns:

{
  "outcome": "pending_approval",
  "approval_id": "appr_01J...",
  "expires_at": "2026-04-15T16:42:00Z"
}

The agent’s response should be: tell the user, “I’ve requested approval to change the subscription to enterprise. A teammate will confirm within an hour.”

Your approval surface — Slack, Linear, an internal admin UI — calls OrmAI’s approval API:

from ormai.approvals import approve, deny

await approve(approval_id="appr_01J...", approver_user_id="[email protected]", note="OK per CSM")
# OrmAI now executes the held call and writes the result to the audit log,
# tagged with the approver's identity.

The original audit row links to the approval row, which links to the executed-write row. Three rows, one chain.

Two-person approval

For the highest-stakes operations, require two distinct approvers:

.require_approval(
    tool="db.delete",
    model="Customer",
    approvers=2,  # two distinct people, neither the requester
)

OrmAI tracks distinct approvers and only executes the call when threshold is met.

Dry-run mode

For risky updates, let the agent (or a developer) preview what would change without executing:

{"name": "db.update", "arguments": {
    "model": "Order",
    "where": {"status": "pending", "created_at__lt": "2026-01-01"},
    "data": {"status": "cancelled"},
    "reason": "Auto-cancel stale pending orders.",
    "dry_run": true,
}}

Returns:

{"affected_rows": 412, "would_change": [
  {"id": 1003, "status_before": "pending", "status_after": "cancelled"},
  ...
]}

No mutation. Safe to inspect. Many of our customers expose dry-run as a separate tool the agent always tries first.

Rate limits on writes

Already covered in the budgets guide. One specific recommendation for writes: keep max_writes_per_minute low (10–20) and use approval gates for the cases that genuinely need higher throughput. Burst-tolerant write quotas without human gates are how you end up with a ledger that has 50,000 incorrect rows on a Tuesday morning.

Rollback by design

OrmAI doesn’t roll back mutations for you (it can’t, in general — your application has side effects beyond the database). But it makes rollback feasible by default:

  • Every write logs the prior state of affected rows.
  • The audit row contains the diff: before and after values for changed fields.
  • A rollback_audit_id(audit_id) admin tool generates an inverse operation that restores the prior state.

This means: when an investigation surfaces “this update was wrong,” you have a one-line rollback that runs in your normal admin tooling, not a database surgery session.

Example: a customer-facing chatbot that can update profiles

Real configuration from a production deployment:

policy = (
    PolicyBuilder(DEFAULT_PROD)
    .register_models([Customer, Subscription, Order])
    # Reads
    .deny_fields("*password*", "*secret*", "*token*")
    .mask_fields(["customer.email", "customer.phone"])
    .tenant_scope("tenant_id")
    # Writes — agent can update non-financial profile fields only
    .enable_writes(
        models=["Customer"],
        writable_fields={"Customer": ["preferred_name", "preferred_language", "communication_opt_in"]},
        require_reason=True,
    )
    .max_writes_per_minute(10)
    # Subscription changes always need approval
    .require_approval(
        tool="db.update",
        model="Subscription",
        approvers=1,
    )
    # Orders are read-only via the agent
    # — no enable_writes() for Order
    .build()
)

Result: the agent can change a customer’s preferred name. It can’t change their email (masked, not writable). It can request a subscription upgrade (queued for approval). It can’t touch orders. Every action is in the audit log with a reason.

Common mistakes

  • Skipping the reason field “because it’s annoying.” It isn’t. Models supply it readily; auditors love it.
  • Granting writes to whole models when you mean specific fields. Use writable_fields.
  • Approval queues without an SLA. Decide what happens when an approval sits unanswered for 24 hours. (We recommend auto-deny.)
  • Approving in chat without logging the approver’s identity. OrmAI logs identity; if you re-implement approval elsewhere, do the same.

Found a typo or want to suggest a topic? Email [email protected].