# Delegated confirmation (/docs/guides/iam/delegated-confirmation)

> How an AI agent acting for a person gets CONFIRMATION_REQUIRED instead of a denial, how the draft waits for approval, and how the retry completes.



An AI agent can act for a person through a Better IAM **delegation**: the person grants the agent a set of
scopes, and the agent assumes the delegation to get a session that is decided as the person, narrowed by those
scopes. The person can also hold some actions back for confirmation, such as creating a reservation. With
delegated confirmation, compute turns a denial of such an action into a
request for the person's approval instead of a dead end: the agent receives `CONFIRMATION_REQUIRED`, the person
approves in Better IAM, and the agent retries the same request, which then goes through.

Delegated confirmation is the v0.3 flow of PLAN.md §3.4 step 7. The API envelope implements it for every method
with an admission, and reservations keep the waiting request as a draft in the `awaiting-confirmation` state.

## Set up a delegation [#set-up-a-delegation]

Delegations, agents and confirmations are Better IAM features. The person grants the delegation and names the
actions to hold back in `confirm`:

```ts title="delegate.ts"
// The organization registers the agent and gives it a key.
const agent = await iam.api.agents.create(orgOwner, { tenantId, name: 'Scheduler', model: 'claude-sonnet-5' });
const key = await iam.api.credentials.create(orgOwner, { tenantId, identityId: agent.id });

// The person delegates reservation work, holding back creation for confirmation.
const delegation = await iam.api.delegations.grant(alice, {
  tenantId,
  agentId: agent.id,
  scopes: ['compute:reservations:*'],
  confirm: ['compute:reservations:create'],
});

// The agent assumes the delegation: its session acts as Alice.
const { token } = await iam.api.delegations.assume(
  { token: key.token },
  { tenantId, delegationId: delegation.id },
);
```

Compute sees the result as a caller with `sessionKind: 'delegated'`, the person's `identityId`, and the
`agentId` and `delegationId`. In policies, such a session has `principal.kind` equal to `user` and
`principal.delegated` equal to true, and `principal.mfa` is always false. See
[Policies](/docs/guides/iam/policies) for guardrails that apply to delegated sessions.

## What happens on a denial [#what-happens-on-a-denial]

<Mermaid
  chart="sequenceDiagram
  participant A as Agent
  participant C as Compute
  participant I as Better IAM
  participant P as Person
  A->>C: reservations.create, key k1
  C->>C: transaction 1 writes the draft rsv_...
  C->>I: require compute:reservations:create
  I-->>C: ACCESS_DENIED
  C->>I: requestConfirmation (reason, validSeconds 900)
  I-->>C: confirmationId, expiresAt
  C->>C: bind the reason's digest, draft to awaiting-confirmation
  C-->>A: 403 CONFIRMATION_REQUIRED
  I->>P: confirmation request
  P->>I: decideConfirmation, approve
  A->>I: getConfirmation (poll)
  A->>C: reservations.create, same input, key k1
  C->>I: require on the same draft
  I-->>C: allowed by the approval
  C->>C: same text as approved
  C-->>A: placed reservation"
/>

When the one decision of an admission is refused and the caller's session is delegated, the envelope asks Better
IAM for a confirmation with the caller's own credential:

1. It sends the decision's action and resource, the reason and `validSeconds: 900`. The reason is the method's own
   description of the request (see [what the person sees](#what-the-person-sees)), else
   `<group>.<method>: <action> on <type>/<id>`.
2. If a pending request for the same action and resource already exists, compute reuses it instead of asking the
   person twice.
3. Better IAM answers `INVALID_INPUT` when the delegation does not hold the action back. The caller then gets a
   plain `ACCESS_DENIED`.
4. Otherwise compute records a digest of the reason against the confirmation (see
   [one approval, one request](#one-approval-one-request)), the caller gets `CONFIRMATION_REQUIRED` (HTTP 403), and
   the method's denial hook parks the draft.

## What the person sees [#what-the-person-sees]

The person approves what the confirmation's reason says, so a method that can be held back describes the request
itself (`MethodAdmission.describe`, ec36c63, security review 14 H1). Without it, an agent under prompt injection
could add an SSH key, more GPUs or a longer lifetime, and the person would read only an action and an id. The
descriptions name every parameter that changes what is booked, what runs, or for how long:

| Method                | The reason names                                                                                                                                                                                                                                                                  |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reservations.create` | The pool; each device request as `<count>× <class>` with its selector, sharing and topology; the duration and start (`now` or an ISO time); a gang's size and topology key; a non-`any` topology; a whole-node binding with its profile and version; spot; the lease; the project |
| `reservations.extend` | The reservation and the absolute end: `prepare` resolves `by` into `until` first, so the handler applies what was described                                                                                                                                                       |
| `instances.create`    | The pool, flavor and image; each SSH key as its OpenSSH `SHA256:` fingerprint, never the key; user data by size and a 12-hex-digit SHA-256 prefix, or its `secret://` reference, never the text; created started or stopped; the project                                          |
| `sandboxes.create`    | The template, the lifetime (or the template's default) and the snapshot it restores                                                                                                                                                                                               |
| `sandboxes.extend`    | The sandbox and the absolute end, resolved as for reservations                                                                                                                                                                                                                    |

```text title="reason"
reservations.create: pool pool_01k5w…; 4× gpu; for 8h from now
```

The text is deterministic (no clock and no database reads), so the same request always yields the same text.
Caller-supplied values are clipped to one short line, with control, format and line-separator characters replaced,
so a name cannot forge extra lines in the prompt.

Better IAM hides the reason for a denial, so compute cannot tell "held back" from "forbidden by policy" before
asking. A request for a confirm-listed action that a policy forbids therefore also creates a confirmation request,
which the approval cannot rescue. Upstream change U8 would remove these futile requests.

## The error [#the-error]

```json title="403 response"
{
  "error": {
    "code": "CONFIRMATION_REQUIRED",
    "message": "The person this agent acts for must confirm the request; retry with the same idempotencyKey once they have",
    "details": {
      "confirmationId": "9c1f0e6a-...",
      "expiresAt": 1790001800000,
      "action": "compute:reservations:create",
      "resource": { "type": "compute-reservation", "id": "rsv_..." }
    }
  }
}
```

<TypeTable
  type="{
  confirmationId: {
    type: 'string',
    description: &#x22;Better IAM's confirmation request. Poll it with delegations.getConfirmation.&#x22;,
    required: true,
  },
  expiresAt: {
    type: 'number',
    description: 'Epoch milliseconds when the pending request lapses undecided, when Better IAM reports it.',
  },
  action: {
    type: 'string',
    description: 'The action that was held back, such as compute:reservations:create.',
    required: true,
  },
  resource: {
    type: '{ type: string; id: string }',
    description: 'The exact resource the approval will open: for a create, the draft id derived from the idempotency key.',
    required: true,
  },
}"
/>

The typed client raises it as a `ComputeClientError` with `code: 'CONFIRMATION_REQUIRED'`, `status: 403` and
these fields in `details`.

## The draft awaiting confirmation [#the-draft-awaiting-confirmation]

For `reservations.create`, the draft stays behind and waits:

| Field                            | Value                                                                                              |
| -------------------------------- | -------------------------------------------------------------------------------------------------- |
| `state`                          | `awaiting-confirmation`                                                                            |
| Condition `ConfirmationRequired` | `status: 'true'`, `reason` is the confirmation id, `message` is the deadline in epoch milliseconds |
| Deadline                         | Better IAM's `expiresAt`, but never more than 30 minutes after the denial                          |
| Event                            | `reservation.awaiting-confirmation`, with `confirmationId` and `expiresAt` in `data`               |

`reservations.get` returns the reservation in this state, so the agent and the person can see what is waiting.
It books nothing and holds no quota. At the deadline the reservation lifecycle moves it to `expired`, a terminal
state, and publishes `reservation.expired` with the reason `confirmation expired`. `reservations.cancel` accepts
only `pending` and `placed` reservations, so a waiting draft cannot be cancelled early (`INVALID_STATE`); let it
expire.

<Mermaid
  chart="stateDiagram-v2
  state &#x22;awaiting-confirmation&#x22; as awaiting
  [*] --> draft
  draft --> awaiting: delegated denial, confirmation requested
  awaiting --> draft: retry with the same key
  awaiting --> expired: deadline passed
  draft --> denied: any other denial
  draft --> placed: allowed and placed
  expired --> [*]
  denied --> [*]"
/>

Other admissions handle the waiting differently. `instances.create` deletes its draft on any denial, but the
draft id is derived from the tenant, the caller and the idempotency key, so the retry recreates the same id and
the approval still matches. A delegated `reservations.extend` clears its pending change on denial and is decided
again on the same reservation when retried.

## Approve and retry [#approve-and-retry]

The person decides in Better IAM, from their own session. Better IAM emails them when it has an address for them
and email delivery is configured, and lists pending requests with `delegations.listConfirmations`:

```ts title="approve.ts"
await iam.api.delegations.decideConfirmation(alice, {
  tenantId,
  confirmationId,
  approve: true, // false rejects
});
```

Better IAM's approval opens that action on that resource within the request's `validSeconds`: compute asks for 900
seconds (15 minutes). The person has 30 minutes to decide before the request lapses. Compute narrows the approval
further to the one request the person saw (below).

## One approval, one request [#one-approval-one-request]

Better IAM keys an approval by action and resource and keeps it open for its validity, so after one approved
`reservations.extend` of `rsv_X` an agent could extend `rsv_X` again, by more, and IAM would allow it. Compute binds
each approval to its text (06d7f04, security review 14 M1):

* When the envelope asks for a confirmation, it records the SHA-256 of the reason the person is shown, keyed by
  tenant, delegation, action and resource, with the confirmation's expiry, in `confirmation_bindings` (migration
  `confirmations/0001_bindings`, owned by an always-hosted `confirmations` module). The first text wins, so a reused
  pending confirmation keeps the text the person saw.
* After IAM allows a delegated call, every unexpired binding on its decision must match the digest of the call's own
  text. Otherwise the call is refused with `ACCESS_DENIED` and `details.reason: 'approval-mismatch'` ("The approval
  given for this resource was for a different request; ask again for this one"), and the method's `abandoned` hook
  clears what `prepare` staged. A decision with no open binding goes ahead as before.

So the retry must be the same request: an agent that changes the lifetime, the devices or an SSH key after the
approval needs a new one.

The agent polls the request and retries with the **same input and the same idempotency key**:

```ts title="agent.ts"
import { createIamClient } from '@better-iam/client';
import { ComputeClientError, createComputeClient } from 'better-compute/client';
import type { compute } from './compute.server';

const client = createComputeClient<typeof compute>({ baseURL: 'https://compute.example.com', token: agentToken });
const iamClient = createIamClient({ baseURL: 'https://iam.example.com', token: agentToken });

async function waitForDecision(confirmationId: string) {
  for (;;) {
    const request = await iamClient.delegations.getConfirmation({ tenantId, confirmationId });
    if (request.status === 'rejected') return 'rejected';
    if (request.expired) return 'expired';
    if (request.status === 'approved') return 'approved';
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

export async function reserve() {
  const input = {
    tenantId,
    poolId,
    devices: [{ class: 'gpu', count: 4 }],
    duration: '8h',
    idempotencyKey: 'nightly-2026-09-24',
  };
  try {
    return await client.reservations.create(input);
  } catch (error) {
    if (!(error instanceof ComputeClientError) || error.code !== 'CONFIRMATION_REQUIRED') throw error;
    const decision = await waitForDecision(String(error.details?.confirmationId));
    if (decision !== 'approved') throw error; // rejected or lapsed: stop here
    return await client.reservations.create(input); // same key: same draft, now approved
  }
}
```

On the retry, `prepare` finds the waiting draft. Before its deadline it moves the draft back to `draft`, removes
the condition and decides again; the approved triple of action, type and id allows it, the call's text matches the
one the person approved, and placement continues as usual. After the deadline the retry returns the `expired` reservation
without a decision. The same key with a different request is `CONFLICT`.

Stop when the person rejects. A retry after a rejection is decided again, denied again, and creates a new
confirmation request, because only pending requests are reused. After an expiry, start over with a new
idempotency key.

The MCP tools follow the same loop: they surface `confirmation-required` with the `confirmationId` and
`idempotencyKey`, poll for up to 25 seconds when the host gives them a way to, and retry with the same key. See
[MCP](/docs/guides/clients/mcp).

## Failing closed [#failing-closed]

Delegated confirmation never reveals more than a denial would:

* A session that is not delegated never triggers a confirmation request; it gets `ACCESS_DENIED`.

* An action the delegation does not hold back gets `ACCESS_DENIED`.

* If asking Better IAM fails, the caller gets `ACCESS_DENIED` and the server logs "Requesting a delegated
  confirmation failed; the request stays denied".

* Only methods with an admission ask. Any other method a delegated session is refused answers `ACCESS_DENIED`.

* A delegated call whose text differs from the one an open approval was given for gets `ACCESS_DENIED`
  (`approval-mismatch`).

`tests/server-admission.test.ts` covers the envelope's side (the text binding included),
`tests/approval-descriptions.test.ts` the descriptions, and `tests/iam-bridge-confirmation.test.ts` the round
trip through a real Better IAM, including the reuse of a pending request and an approval that opens only the
draft it was asked for.

  - [Reservations](/docs/guides/capacity/reservations): Drafts, admission, placement and the full reservation lifecycle.

  - [Policies](/docs/guides/iam/policies): `agent-guardrails` and the conditions for delegated sessions.

  - [MCP](/docs/guides/clients/mcp): Compute's tools for AI agents, which handle confirmation for you.
