---
title: "Configure approval ownership and deadlines"
description: "Configure approval ownership and deadlines for people running and reviewing agents."
canonical_url: "https://docs.oz0.ai/agents/approvals/configure"
---
# Configure approval ownership and deadlines

> Configure approval ownership and deadlines for people running and reviewing agents.

For: people running and reviewing agents.

## Require approval for a tool

Some calls should not happen without a person saying yes: merging a pull request, sending money, deleting data. List them in the tool's `requires_approval`, and every call to them waits for a decision.

```yaml [oz0-plugin.yaml]
tools:
  - id: acme_github
    mcp: ["python", "-m", "acme_github.mcp"]
    requires_approval: [acme_github.merge]
```

The tool's plugin decides this, so it holds for every agent that uses the tool, from any plugin.

## Who decides

By default any operator, admin or owner of the tenant decides. A tool may say who decides its calls, in its manifest:

```yaml [oz0-plugin.yaml]
tools:
  - id: payments
    mcp: ["python", "-m", "payments.mcp"]
    requires_approval: [payments.refund]
    approval:
      deciders: {teams: [payments]}   # or role: admin, or people: [user:ada]
      not_starter: true               # never the person or app that started the job
```

A tenant's [tool policy](https://docs.oz0.ai/govern/tool-policy) may say it per rule, for any tool, with a rule that asks (Ask):

```bash [Terminal]
oz0 policy ask payments.refund --deciders team:payments --not-starter --reason "refunds are the payments team's"
oz0 policy ask 'crm.delete_*' --deciders role:admin --reason "deletes are an admin's"
```

(With `oz0 tenant tool-policy set`, the same rule is `--keep-rules --rule 'payments.refund=approve if decided-by=team:payments not-starter'`.)

### Decider requirements

A decider must hold at least the `role` (`operator`, the default, or `admin`), and, when teams or people are named, be one of the people or a member of one of the teams.

With `not_starter` the job's starter (the person, or the app's key, that started the job tree; see [the audit trail](https://docs.oz0.ai/govern/audit-trail/records#who-started-it)) may not approve: four eyes. They may still deny.

An API key counts as the person who created it, both ways: nobody approves the job their key started, nor their own job with a key they hold.

The key's creator is recorded when the job starts, so revoking the key first does not change that.

In a chat thread that runs as the binding's identity, the person who started the thread may not approve from the chat either.

While who started the job cannot be read (its database away a moment), a decision that four eyes checks is answered as unavailable, and the person decides again shortly.

Four eyes compares the identities the platform knows; a second account of the same person is beyond it.

When both the tool and a rule name deciders, a decider must satisfy both.

A rule's deciders count whatever its other conditions say, so the stricter reading wins. That includes the rules that act apart from the order of the rules: the taint-default and label-egress floors, the origin-default rule (for every function, as it acts), and origin and sequence rules.

### Review new decider grants

Naming a team or a person gives its members who hold only `approvals.decide.named` a say they did not have, on every call the rule acts for, and a floor or gate acts for many. So a tool-policy change that names a team, a person or a key anew in a rule's deciders, a floor's or gate's included, is a [reviewed change](https://docs.oz0.ai/govern/reviewed-changes): it takes a reason and, with four eyes on, waits for another admin.

### Enforcement and unavailable checks

The edge checks this on every decision, from the console, the CLI, a chat channel or your own app, from the tool's manifest and the tenant's policy as the edge has them, not from what the job's node says.

A refused decision is audited as `run.approval.refused`, and the card says who may decide: *This approval needs a member of team payments, not the job's starter.* Notifications go to exactly these people.

While an edge does not hold the tenant's tool policy (just after it starts, or while a stored policy cannot be read) or has not read the tenant's plugins yet, it cannot tell whom a rule names, so nobody approves through it: the decision is refused as unavailable, and the card says so; try again shortly.

Likewise, while who started the job cannot be read (its database does not answer), a yes under `not_starter`, or on the second approval of a change to a plugin's tests or rules, is refused as unavailable rather than decided without knowing whether the decider started the job.

The same holds for a tool whose plugin's manifest the edge cannot read (a damaged or unsupported stored bundle): the card names the plugin, and reinstalling or updating it lets the approval be decided again.

A denial from the console, the CLI or your own app goes through, since it stops the call.

### Keys that decide

A tenant API key decides an approval only when it is named for it: by the approval's deciders (`keys:` in the manifest's `deciders`, by key ID, at most 8; `--deciders key:<id>` on a tool-policy rule), or by the [notification rule](https://docs.oz0.ai/admin/notifications#decide-from-your-system-submit-and-poll) whose webhook delivered the approval (`--decide-key`). That is how an integration a tenant admin chose, such as a ticket system's bridge, decides; its decisions are audited as the key, with the rule that named it.

Any other key is refused with `403` and the job's link in the console, whatever its role, on every route: the runtime API, its MCP server and A2A.

The approval's other deciders still hold for a named key.

A set that names only keys leaves people as they were: it adds the keys, it takes nobody away.

Replace `KEY_ID` below with the ID of the key that will decide approvals.

```yaml [oz0-plugin.yaml]
approval:
      deciders: {teams: [payments], keys: [KEY_ID]}   # a tenant's own plugin: a member of payments, or its bridge's key
```

```bash [Terminal]
oz0 policy ask itsm.close_ticket --deciders key:KEY_ID --reason "the ticket system's bridge decides"
```

A question an agent asks is answered by a key only when the question's deciders or a notification rule name it, or through the protocol's own elicitation: an MCP or A2A client answers the questions of the task it started, in that task's `tasks/update` or A2A message, as those protocols ask it to. The same key through `DecideApproval`, or for a task it did not start, is refused like any other key. People are not keys: a person signed in, a person acting through an app, and an operator's certificate decide as before.

A notification rule names a key for the approvals it delivered to the webhook that names it (any step of the escalation ladder, while the target still names the key and goes to the same address). The receiver may answer within the delivery's own request, before the delivery is recorded everywhere: for the first five minutes after an approval asked, a rule that already stood when it asked and matches its `approval.waiting` or `question.waiting` event names its key too. A rule that sends only escalations names its key only once an escalation was delivered.

Naming a key gives it a power no key has otherwise, so it is a [reviewed change](https://docs.oz0.ai/govern/reviewed-changes): a tool-policy rule whose deciders name a key, and a notification rule whose webhook names one, sends it other approvals or moves its address, take a reason and, with four eyes on, another admin's approval.

## Deadlines

Every approval has a deadline, after which it is denied, never approved. The most specific place that sets one wins:

1. a rule of the tenant's [tool policy](https://docs.oz0.ai/govern/tool-policy/review-changes#approval-deadlines-and-escalation) that names the function (`deadline: 2h`);
2. the tool, in its manifest:```yaml [oz0-plugin.yaml]
tools:
  - id: bank
    mcp: ["python", "-m", "bank.mcp"]
    requires_approval: [bank.pay]
    approval:
      deadline: 2m          # at least 1m, at most 720h (30 days)
```
3. the agent's definition:```yaml [agents/payer.yaml]
approvals:
  deadline: 4h
```
4. the tenant's default, `oz0 tenant tool-policy set --approval-deadline 8h` (24 hours when unset).

This holds for calls in `requires_approval`, calls the tenant's tool policy sends to a person and hook escalations. An agent's [questions](https://docs.oz0.ai/agents/asking-people) keep `ask_user.timeout`, a flow step keeps its own `timeout`, and [budget requests](https://docs.oz0.ai/spend/budgets-and-quotas/requests#ask-for-more) and routine outputs take the tenant's default. The card shows where the deadline came from.

A decision that comes at or after the deadline is refused, whatever route it takes (the console, the CLI, a chat thread, an app, an AG-UI resume): `approval a-… passed its deadline (…), which denies it`. That holds while the job still lists the approval because its node was away when the deadline came; the job refuses such a decision as well, and its wait ends as the deadline's denial when it runs again.

## The escalation ladder

An approval that waits tells more people as its deadline nears. It is on by default:

<table>
<thead>
  <tr>
    <th>
      When
    </th>
    
    <th>
      Who is told
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      when it is asked
    </td>
    
    <td>
      the people who may decide, when a <a href="https://docs.oz0.ai/admin/notifications">
        notification rule
      </a>
      
       sends <code>
        approval.waiting
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      a quarter of the deadline
    </td>
    
    <td>
      a reminder to the people who may decide
    </td>
  </tr>
  
  <tr>
    <td>
      half
    </td>
    
    <td>
      the agent's owner (its <a href="https://docs.oz0.ai/govern/inventory">
        owner person
      </a>
      
      , or its owner team's admins when it has none, then the team's members; the tenant's admins when none of them is still in the tenant)
    </td>
  </tr>
  
  <tr>
    <td>
      three quarters
    </td>
    
    <td>
      the tenant's admins
    </td>
  </tr>
  
  <tr>
    <td>
      the deadline
    </td>
    
    <td>
      the call is denied by <code>
        deadline
      </code>
      
      , never approved
    </td>
  </tr>
</tbody>
</table>

### Escalation records and delivery failures

Each step closes the one before: the card, `oz0 approval list` (the `STEP` column) and a chat thread say which step it is on, and nothing stays actionable after the deadline. Each step is in the audit trail as `run.approval.escalated` with the step and how many people of which kind were told (never their addresses), and the refusal as `run.approval.expired`.

A step that names people but tells none of them is recorded as `run.approval.unnotified`, with the reason. This happens when nobody it names has a notification address, it names nobody, no notification rule matched, or the server sends no notifications. The same event records a notice the server took but could not send in any of its tries.

Both cases are marked on the approval. A step whose node could not reach the server is marked there too, but has no audit event because the server never heard of it. The card says **Nobody was told at step N of its escalation**, and `oz0 approval list` shows `UNNOTIFIED:N`.

Rules by risk read a function whose listing cannot be read as one that declares nothing (destructive and reaching outside the tenant), so a rule for destructive calls still tells its people.

The job waits for the edge to take that last step (at most three minutes) before it goes on, so the refusal is notified and audited even when it ends the job, as a question with `on_timeout: fail` does.

People hear of a step by email, webhook or chat when the tenant has a [notification rule](https://docs.oz0.ai/admin/notifications) for `approval.escalated`.

A tool, an agent, a rule or the tenant may shape its own ladder, with at most five steps:

```yaml [oz0-plugin.yaml]
approval:
      deadline: 8h
      escalation:
        - { at: 0.5, to: team:payments }
        - { at: "90%", to: role:admin }
```

`to` is `deciders`, `owner`, `admins`, `role:<reader|operator|admin|owner>` or `team:<name>`. `escalation: off` turns the ladder off, which loosens oversight, so it takes `escalation_reason`; `default` (or `on`, or nothing) is the default ladder, and any other word is an error, in a manifest, an agent and the tool policy alike. Each `at` is a finite fraction strictly between 0 and 1, later than the step before. The ladder's timers run in the job itself: a ladder survives restarts and lost servers, and only the edge sends the emails, webhooks and posts, once per job, approval, step and person.

When nobody who may decide a tenant's approvals has signed in or decided for 14 days (`oz0 tenant notify-content <tenant> --inactive-days N`), every waiting approval says so, and `oz0 doctor` lists the tenant under `approvers`.

An approval that only some teams may decide (the deciders a tool-policy rule or a tool names) says so too when nobody in them was active in that time, even though others in the tenant were, but only for teams of at least five people who may decide.

An approval that names a person, or a smaller team, never says it: everyone who may list the tenant's approvals would learn who has been away. `oz0 doctor` counts the tenant as a whole only.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
