03. The four authority classes¶
The class field decides every downstream governance question. Approval gates, retry policy, audit retention, deploy permission, scope bindings, alerting thresholds — all of these read off the class. This chapter builds the four classes from the blast radius they each cover, and shows exactly what changes in the rest of the contract when you pick each one.
A platform tech lead at a Gurgaon B2B SaaS sits in a design review for a new "customer success" agent. The product wants the agent to summarise tickets, send follow-up emails, schedule renewal calls, log activity in the CRM, and — for the highest-paying tier — extend trial periods up to thirty days when a customer asks. Five tools are proposed. The team has classed them: "all of these are basically writes, we'll wire them with the same retry policy and audit level." The tech lead pushes back: "extending a trial is not the same class of action as logging an activity, even though both are writes. If the model extends a trial twice because of a retry, we just gave away a month of revenue. If the model logs an activity twice, the CRM has a duplicate row." The team agrees they need to split the class. By the end of the meeting the five tools are sorted into four classes, each with a different retry policy, different scope binding, and different audit retention. The trial-extension tool also acquires a human-approval gate above ten days. The change took twenty minutes. It would have taken a quarter to add after the first incident.
This chapter teaches that twenty-minute conversation. The four classes are a discipline that compresses dozens of downstream decisions into one upstream choice.
The four classes¶
Memorise these once. Every tool fits exactly one.
| Class | Definition | Worst-case blast in one call |
|---|---|---|
| read | Returns data; no side effect on production state, on users, or on third parties | Stale data shown to one user; rate quota burned |
| write-idempotent | Changes state; same idempotency key always produces the same outcome | Surprise state at a known key (recoverable by overwrite) |
| write-non-idempotent | Changes state; repeating the call produces duplicate or accumulating effects | Two of the same thing; double-charge; double-message |
| irreversible | Changes state in a way that cannot be undone by any tool in the platform | Permanent: money sent, data deleted, contract terminated |
A fifth designation, human-gated, is not a class — it is a modifier on a class that says "even though the class would normally allow the call, an out-of-band human approval is required first." A human-gated irreversible is the strictest combination; a human-gated read is rare but exists for genuinely sensitive reads (e.g., medical records).
The chapter walks each class, in increasing blast-radius order, and shows what fields change as a consequence.
Class 1 — read¶
A read tool returns information. It must not mutate state in any system the platform considers production. It must not emit notifications. It must not write to the audit log of the downstream system (though the contract layer will write to its own audit log, see chapter 11). It may consume rate quota from a vendor — that is a cost, not a side effect, and lives in operational metadata.
Examples that are correctly classed read:
get_customer_orders(customer_id)— fetches order listsearch_knowledge_base(query, top_k)— runs retrieval against a vector storelookup_address(address_id)— fetches a stored address
Examples that look like reads but are not:
get_or_create_user(email)— haswrite-idempotentsemantics behind the readfetch_audit_log(...)— fine as a read, but the target of the read is sensitive; class is read, but the scope must be restrictiverun_sql(query)— schema cannot tell whether the query isSELECTorDELETE; class is whichever is highest in the union of what the schema allows. In practice, arun_sqltool with no parser is automaticallyirreversible.
What the rest of the contract looks like for a read:
class: read
class_rationale: Pure read; no mutations.
operational:
idempotency:
required: false # reads are naturally idempotent
scope:
required_scope: "customers:orders:read"
tenant_binding: true
rate_limits:
per_agent_per_minute: 120 # reads can run hotter
observability:
audit_retention: 30d # shorter retention is acceptable
pii_fields: [email] # but still record what was read
sla:
p95_latency_ms: 500
availability: "99.9%"
Operational consequences: - Idempotency key not required (reads are naturally idempotent in the dedup sense). - Rate limits can be relatively high; reads do not accumulate damage. - Audit retention can be shorter, but reads still go in the audit log — a sequence of reads can leak information even if no single read does. - Approval gates are not used. - Retries are unrestricted (within rate limit).
Common misclass. A "read" that returns a fresh, expensive computation — e.g., compute_credit_score(customer_id) — is sometimes classed read because nothing is stored. If the computation has cost (model calls, vendor API spend), the class is still read, but the rate limit must be designed around the cost, not the latency. Read-but-expensive is the most common reason to set a low per-minute cap on a read tool.
Class 2 — write-idempotent¶
A write-idempotent tool changes state, but the change is keyed on something the caller provides. Two calls with the same key produce the same result. The classic example is "upsert with a client-provided ID."
Examples:
upsert_customer_profile(customer_id, fields)— second call overwrites the same rowset_user_preference(user_id, key, value)— value-by-key; deterministicrecord_idempotent_event(event_id, payload)— explicit dedup keytag_resource(resource_id, tag)— tag is a set; adding the same tag twice is a no-op
What changes in the contract:
class: write-idempotent
class_rationale: |
Updates the customer profile; the customer_id acts as the idempotency
key (one profile per customer). Re-sending the same payload produces
the same result.
operational:
idempotency:
required: false # natural via the resource ID
key_field: customer_id # the *resource* is the key
dedup_window: forever # not bounded by time
scope:
required_scope: "customers:profile:write"
tenant_binding: true
rate_limits:
per_agent_per_minute: 60
observability:
audit_retention: 365d # writes are kept longer
Operational consequences: - Idempotency key is naturally present (the resource ID). The contract does not require a separate field, but it should note which field carries the key. - Retries are safe within the dedup window. The dedup window for class 2 is typically "forever" because the resource ID is permanent. - Audit retention extends to at least a year (or longer per regulatory requirement). - Scope is bound to the specific write capability and tenant.
Why class 2 matters separately. The reason to distinguish write-idempotent from write-non-idempotent is retry safety without coordination. A class 2 tool can be retried automatically by the contract layer on transient errors without any per-call dedup ledger. A class 3 tool cannot.
Common misclass. "Upsert by name." If the resource is keyed by customer_name, the operation looks idempotent but is not: two customers with the same name collide, and re-running the call after a name change creates a new resource. Class 2 requires the key be immutable. If the key is human-mutable or non-unique, the tool is class 3.
Class 3 — write-non-idempotent¶
A write-non-idempotent tool changes state in a way that accumulates with each call. Repeating the call duplicates the effect. The tool is safe to retry only if a per-call idempotency key is provided and the contract layer enforces dedup.
Examples:
issue_refund(payment_id, amount, idempotency_key)— two calls = two refunds without dedupsend_email(to, template, params, idempotency_key)— two calls = two emailscreate_lead(...)— two calls = two leads with the same contentlog_activity(customer_id, activity)— two calls = duplicate activity rowscharge_card(amount, idempotency_key)— two calls = two charges
What changes in the contract:
class: write-non-idempotent
class_rationale: |
Each call creates a new artefact in the downstream system. Retries
without an idempotency key produce duplicates.
schema:
parameters:
required: [..., idempotency_key]
properties:
idempotency_key:
type: string
pattern: "^[A-Za-z0-9_-]{16,64}$"
description: |
Caller-generated unique key. Re-sending the same key within the
dedup window returns the original result without producing a
duplicate side effect.
operational:
idempotency:
required: true # contract layer rejects calls without it
key_field: idempotency_key
dedup_window: 24h
storage: contract-layer-redis
scope:
required_scope: "<specific:write>"
tenant_binding: true
rate_limits:
per_agent_per_minute: 10
per_tenant_per_hour: 500
observability:
audit_retention: 7y # often regulatory
require_full_payload_log: true
Operational consequences: - Idempotency key is mandatory; contract rejects calls without it. - Dedup window is bounded — typically 24h to 7d, depending on how long retries are reasonable. - Retries by the contract layer are permitted only when the same idempotency key is preserved. - Audit retention extends to whatever the regulatory regime requires (often 7 years for financial actions). - Full payload logging is on by default; redaction handled per chapter 11.
The idempotency key contract. Chapter 04 builds this in detail. The summary: the caller (the contract layer wrapping the model's tool call) generates a key per logical action; if the same key arrives twice, the contract returns the stored result without re-executing. Two retries become one outcome.
Common misclass. Treating send_email as a read because "the email isn't stored anywhere we own." The blast is the recipient's inbox; the cost is brand damage and unsubscribe rate. Class 3 with rate limits tuned aggressively.
Class 4 — irreversible¶
An irreversible tool changes state in a way that cannot be undone by any tool in the platform. The check is not "can we technically undo this" but "does there exist a tool the agent can call to reverse this within the contract's reversibility window?"
Examples:
delete_customer(customer_id)— GDPR delete; data is gonecancel_subscription(subscription_id)— possibly reversible via support, not via toolsend_wire_transfer(amount, destination)— cannot be reversed by the agentterminate_contract(contract_id)— legal action; no programmatic reversalpublish_to_audience(message, audience_id)— once sent, every recipient has seen it
What changes in the contract:
class: irreversible
class_rationale: |
Wire transfers cannot be reversed via any tool. Recovery requires
manual finance-ops action through the bank.
reversibility: |
None via tools. Manual reversal requires finance-ops + bank cooperation,
typically 2-5 business days, with success rate < 30% if recipient has
withdrawn funds.
human_gating:
required: true # almost always for irreversible
approver_role: finance-approver
approval_window_minutes: 60 # after this, gate must be re-requested
default_above_threshold:
field: amount_minor
threshold: 10000000 # 1 lakh INR
behavior: require-approval
operational:
idempotency:
required: true
key_field: idempotency_key
dedup_window: 7d
scope:
required_scope: "treasury:wire:execute"
tenant_binding: true
additional_constraints:
destination_allowlist: required # see chapter 06
rate_limits:
per_agent_per_minute: 1 # very low
per_tenant_per_day: 50
observability:
audit_retention: 10y
require_full_payload_log: true
require_signed_audit: true # tamper-evident audit
alerting:
on_call: every-execution # page on every successful call
on_failure: every-failure
Operational consequences: - Human gating is the default; the absence of gating must be explicitly justified. - Approval is scoped (a role, not a specific person) and time-bounded (the approval expires). - Rate limits are very low; this is the throttle on damage. - Audit is signed and retained for years. - Alerting pages on every execution, not just on failure — irreversible actions are interesting even when they succeed. - Scope often carries additional runtime constraints (e.g., destination allowlists).
Common misclass. Treating a "soft delete" as reversible because the row still exists. If the agent has no tool to un-delete, and "manual support intervention" is the reversal path, the class is irreversible from the agent's point of view. The blast on the agent's side is what matters.
The decision: which class is this tool?¶
Ask the questions in this order. The first "yes" wins.
- Does this call permanently change something nothing can undo? → irreversible.
- Does repeating this call duplicate or accumulate effects? → write-non-idempotent.
- Does this call change state, but only as a function of an immutable key? → write-idempotent.
- Does this call change nothing in production state? → read.
Two refinements catch the common errors:
- If the schema permits inputs that could span multiple classes (e.g.,
run_sql), take the highest class the schema permits. - If a "read" carries cost or rate concerns, it is still a read, but rate limits must reflect the cost.
What the class decides downstream¶
This table summarises the downstream consequences. It is the map between class and the rest of the contract.
| Field | Read | Write-idempotent | Write-non-idempotent | Irreversible |
|---|---|---|---|---|
| Idempotency key | Not required | Resource ID acts as key | Required | Required |
| Dedup window | N/A | Forever | 24h–7d | 7d+ |
| Retry policy | Unrestricted within rate limit | Auto-retry with backoff | Retry only with key preserved | Retry only with key preserved, but consider human re-approval |
| Scope binding | Tenant + capability | Tenant + capability | Tenant + capability | Tenant + capability + extra constraints (allowlists) |
| Rate limit (agent/min) | ~120 | ~60 | ~10 | ~1 |
| Audit retention | 30d | 365d | 7y (often regulatory) | 10y, signed |
| Human gating | No | No | Conditional (above amount/risk threshold) | Default yes |
| Full payload logging | Optional | Recommended | Required | Required |
| Alert on success | No | No | No | Yes |
The point of the table is not to copy these numbers literally — they vary by domain. The point is that every column is decided once the class is set. Class is the upstream choice; everything else falls out.
How to recognise class drift¶
Classes drift in two directions, both bad.
Drift down (class made weaker than reality). A tool was once write-non-idempotent. Someone "made it idempotent" by adding an idempotency key but did not change the underlying system to honour it. The class label says write-idempotent; the reality says write-non-idempotent. Symptom: duplicates appear in production, the team blames the model, the actual cause is mislabelled class. Catch this by reviewing the side-effects field whenever the class is downgraded.
Drift up (class made stronger than necessary). A write-idempotent tool gets relabelled irreversible "to be safe" after one bad incident. Approval gates now block every call. Throughput craters. Catch this by demanding rationale on every class change — including upgrades. Strictness costs throughput; the cost is real even if the safety is real too.
Interview Q&A¶
Q1. A teammate proposes a "delete_customer" tool with class write-idempotent. They argue the customer_id is the key, so repeating the call is a no-op. What do you say?
Write-idempotent is about the key making the operation deterministic, not about reversibility. Delete is irreversible: once the customer is deleted, no tool can restore them. The correct class is irreversible, with human gating, scope-bound to a "data-deletion" role, audit retention to the legal limit. The fact that the second delete is a no-op is incidental — the first delete is permanent. Wrong-answer notes: confusing dedup with reversibility is the most common class-1 mistake.
Q2. You have a run_sql(query) tool that accepts arbitrary SQL. What class is it, and why?
Irreversible, until you parse and constrain the query. The schema permits DROP TABLE, DELETE FROM, and any other destructive statement; the contract has no way to refuse them. You either (a) split into two tools — run_select_sql (class read) and run_mutation_sql (class irreversible) — with parsers enforcing the split, or (b) leave it as one tool with class irreversible and full human gating. Treating it as anything weaker means the class label lies about what the tool can do. Wrong-answer notes: "depends on the query" is the wrong frame; the class is set by what the schema permits, not what a given call happens to do.
Q3. Why do irreversible tools alert on success, not just on failure? Because the interesting event is "this thing happened," not "this thing failed." Success is the blast; failure is recoverable. An irreversible success is the only point at which an on-call can intervene (escalate, freeze, communicate downstream). Alerting only on failure means the team finds out about successful damaging actions through downstream complaints. Wrong-answer notes: "to be paranoid" is the surface answer. The real reason is that success is the incident.
Q4. The compliance team wants every tool to be human-gated by default. What is the right pushback? Human gating has a throughput cost that is real even when it is bought with safety. Default-gating reads makes reads useless. Default-gating idempotent writes makes routine operations slow. The right policy is gate by class: irreversible by default, write-non-idempotent above a value threshold, write-idempotent and read by exception. The discussion with compliance is "we will gate where the blast warrants it, not uniformly." Wrong-answer notes: agreeing to default-gate everything is technically safe but produces an unusable system; the compliance team should be helped to see the tradeoff.
What to do differently after reading this¶
- Audit every existing tool against the four classes. Re-label any that are misclassed. Run the side-effects field through this lens.
- When a new tool is proposed, decide class before schema. The class decides which fields are mandatory.
- Build the class-to-policy table for your platform — your numbers, your domain — and make it the default config for new tools.
- When someone proposes downgrading a class ("can we make this idempotent?"), check that the underlying system supports it, not just that the contract label changes.
Bridge. Once a tool is classed write-non-idempotent or irreversible, the next question is concrete: how does the contract layer actually enforce that retries don't duplicate the effect? Idempotency keys are easy to name and hard to implement correctly under a model client that can retry semantically. The next chapter builds the idempotency mechanism — key generation, dedup windows, storage, edge cases. → 04-idempotency-and-retry-safety.md