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
, 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
Delegations, agents and confirmations are Better IAM features. The person grants the delegation and names the
actions to hold back in confirm:
// 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 for guardrails that apply to delegated sessions.
What happens on a denial
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:
- 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), else<group>.<method>: <action> on <type>/<id>. - If a pending request for the same action and resource already exists, compute reuses it instead of asking the person twice.
- Better IAM answers
INVALID_INPUTwhen the delegation does not hold the action back. The caller then gets a plainACCESS_DENIED. - Otherwise compute records a digest of the reason against the confirmation (see
one approval, one request), the caller gets
CONFIRMATION_REQUIRED(HTTP 403), and the method's denial hook parks the draft.
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 |
reservations.create: pool pool_01k5w…; 4× gpu; for 8h from nowThe 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
{
"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_..." }
}
}
}Prop
Type
The typed client raises it as a ComputeClientError with code: 'CONFIRMATION_REQUIRED', status: 403 and
these fields in details.
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.
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
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:
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
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(migrationconfirmations/0001_bindings, owned by an always-hostedconfirmationsmodule). 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_DENIEDanddetails.reason: 'approval-mismatch'("The approval given for this resource was for a different request; ask again for this one"), and the method'sabandonedhook clears whatpreparestaged. 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:
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.
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_DENIEDand 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.
Better Compute is created by Sean Filimon
Last updated
Billing and usage
How Better Compute meters reserved time into hourly usage windows, records them on Better IAM meters with poison isolation, and where budgets apply.
Nodes
The node model in Better Compute, with its independent state axes, role, trust, hardware key and generations, and how a server goes from join to schedulable.