# Human approval per credential

Source: https://docs.imprezahost.com/security/human-approvals.html

Human approval (human-in-the-loop) lets you mark, per credential, the operations
that must **not** run on the credential's own authority. A marked operation does
not execute: it becomes an **approval request** pinned to the exact call, a
person with management access approves or denies it in the clientarea, and only
then — re-submitted with the approval id — does the operation run. Once.

This exists for the customer who hands an AI agent a credential and still wants
a person in front of the irreversible calls. Scopes decide what a credential may
do at all; spend ceilings decide how much it may spend; this decides **who says
yes** to the calls that matter.

## The policy

A policy is configured per credential in the clientarea
(**API Management → Approvals**), by the account owner. It names:

| Field | Meaning |
| --- | --- |
| `classes` | Operation classes to gate: `destructive` (delete, uninstall, terminate, cancel), `spend` (orders, upgrades, payments), `security` (rotate or revoke credentials, mint sub-credentials, export or rotate the .onion key, install the Impreza agent). |
| `operations` | Individual MCP tool names to gate, e.g. `impreza_order_vps`. |
| `ttl_minutes` | How long a request lives: 15–60 minutes, default 30. |

**Default is off.** A credential with no policy behaves exactly as before —
existing credentials change nothing until the owner marks them. A
**sub-credential never loosens its parent**: the effective policy is the union
of the parent chain's policies, so a child inherits every requirement and can
only add more, and tightening a parent later tightens every child with it.

## What happens when an operation is marked

1. The agent calls the operation. It does **not** run. The answer is a stable
   `approval_required` result with the `approval_id`, the expiry, and the
   clientarea link — nothing was executed and nothing was changed.
2. A webhook event `agent.human_approval_requested` is queued for the account
   (throttled per credential, like the spend-ceiling notices).
3. A person approves or denies in **API Management → Approvals**. The page shows
   the operation, the credential, and a summary of the call. Argument values
   that could be secrets were never stored, so the page cannot show them.
4. Once approved, the agent re-submits the **same** call with
   `approval_id="apr_…"`. The operation then runs.
5. Denied and expired requests also become webhook events
   (`agent.human_approval_denied`, `agent.human_approval_expired`).

## The rules that make it safe

- **Pinned to the exact call.** The request stores a digest of the normalized
  arguments. Re-submit with any argument changed and the execution is refused —
  approving "uninstall dpl_a" approves exactly that, not "uninstall anything".
- **Single use.** Each approval executes exactly once. Two concurrent
  re-submissions race on an atomic claim and only one dispatches.
- **A credential never approves.** There is no API route and no MCP tool that
  approves, denies, or edits a policy. Decisions exist only as clientarea POSTs
  behind a session, a team permission and a CSRF token — so an agent cannot
  approve itself, through a sub-credential, or through an API key. Read-only
  members see the queue but cannot decide; only a person with the
  **manage products** permission can approve or deny, and only the **owner**
  changes policies.
- **Independent of the other guards.** The scope ladder, the two-step
  confirmation and the daily/monthly spend ceilings all still apply to an
  approved call. Human approval adds a check; it never removes one.

## Plan-bound host request limits

The VPS reinstall pilot requires a separate owner grant for each credential,
operation and server, followed by approval of the exact stored plan. Its request
limit counts plans created over the last 24 hours across **all servers for the
credential**, rather than starting a new counter for each server. The pending
plan/operation limit uses the same shared scope. Denied, expired and completed
requests still count toward the 24-hour request limit until they age out.

For a sub-credential, each credential in its parent chain is checked against its
shared counts. The selected server grant and the strictest parent limits apply;
plans requested by siblings also use the parent's allowance. Granting another
server or increasing one server's limit does not reset these counters. A grant
never replaces the human decision or permits an operation on another server.

## Reading the state

- **REST:** `GET /v1/approvals` (the queue), `GET /v1/approvals/{approval_id}`
  (one request), `GET /v1/credentials/{id}/hitl-policy` (a credential's stored
  and effective policy). All read-only.
- **MCP:** `impreza_list_approvals`, `impreza_get_approval` and
  `impreza_get_credential_policy` — the same reads for the agent, so it can
  tell you what is parked instead of blindly retrying. Approving is not a
  tool and never will be.
- **Minting:** `impreza_mint_subcredential` (and `POST /v1/credentials`)
  accepts a `hitl_policy` at creation — it can only add requirements, never
  drop one the parent carries.

## Audit trail

The per-credential activity extract (`GET /v1/credentials/activity?format=csv`,
or `impreza_agent_activity`) records the whole lifecycle next to the tool
calls: `hitl:request`, `hitl:approve` or `hitl:deny` with **who decided**,
`hitl:execute` when the approved call ran, and `hitl:expire`. Secret values and
sensitive parameters are never written — the rows carry identifiers only.


## Revoking plan-bound host permissions

The account owner can revoke the VPS reinstall pilot's explicit host grant even
while the owned server is suspended. The credential must still be active and
owned by the account. New or renewed grants require an active server.

Reactivation does not undo a revocation. The revoked marker stays stored, so the
credential cannot fall back to the legacy reinstall route. A previously approved
plan is refused before dispatch. Restoring permission requires a new explicit
owner grant after the service is active again; it does not revive an older plan.
Revocation does not cancel an operation already submitted to the provider, whose
receipt can still be reconciled without restoring the old grant.


## Managed ingress reload

Host plans also offer `managed_service.reload`: a reload of the managed ingress
on a server you own, approved by a person and applied once. It needs **Impreza
agent 0.6.36 or later** and a managed ingress unit that declares the fixed
reload (`ExecReload`); the agent version alone is not enough. The loaded unit
and its reload capability must be confirmed on the server before a plan is
offered. Missing or changed reload support is a refusal, not
proof that a reload works, and the capability read answers `503` on a server
where it is not confirmed. VPS reinstall keeps its existing contract.

Managed reload is offered on Ubuntu 24.04 (amd64) servers. The owner grants the exact operation/server in the portal. Every credential ancestor must also grant that
same operation. `manage` alone is insufficient. Renewing the OAuth credential of a
connected AI tool keeps the same identity and its grants, so a granted
operation is not lost at renewal; an explicit reauthorization revokes the previous
credential. A grant is still not permanent: revoking it removes it. A verified operator recovery and
health binding, active managed ingress unit with explicit reload support, current
agent generation and an independently owned affected-app set are prerequisites.
The agent creates its own maintenance journal directory when it is
missing; you do not prepare it by hand.

Read `GET /v1/host/services/{service_id}/maintenance`, then prepare:

```json
{"service_id":799,"operation":"managed_service.reload","ttl_seconds":300}
```

Use `POST /v1/host/plans` or `impreza_prepare_host_plan`. Reload accepts only
those three fields; the technical TTL is 30–300 seconds (default 300). No unit,
command, path, environment, template, package or restart fallback is accepted.
The stored preview binds owner, credential, host generation/boot/state,
capability, affected application references, connection-loss warning and deadline.
An independent human must approve that exact immutable plan in the portal.
The approval and the dispatch check compare the plan in a canonical form, so the
way the database stores the preview cannot make a valid approval fail.

Apply through the existing plan endpoint/tool with exactly the returned
`plan_id`, `plan_digest` and `approval_id`. Acceptance means a durable queued
intent, not completion. Revocation/drift/expiry is checked again at poll dispatch.
Other host writers are reserved out while the reload is pending. Reading the plan
never redispatches; repeating apply returns the same plan, not another command.
An `uncertain` or `partial` result stays visible for reconciliation. The agent's
reconciliation observes the existing local journal before the next poll. It can
complete only a durably acknowledged successful effect with fresh, matching
agent/generation/boot/state/capability/app identity before and after route health.
A healthy unit alone never proves that the original effect completed. A crash
before the effect acknowledgement was durably saved, failed IPv6 after IPv4,
unknown result, changed identity, cancelled reservation or backward clock is not
certified; it is left for explicit operator reconciliation.

The agent never reloads or restarts during recovery. Its successful observation
is saved before transmission and acknowledged by exact receipt, not just command
ID. Authenticated result processing admits one monotonic partial-to-completed
transition bound to the same plan, command, generation and fence; the original
receipt bytes and original partial audit event remain preserved. Lost responses
repost the same receipt; late partial/refused or conflicting observations cannot
reopen a completed plan. A cancelled plan with a possible effect cannot be
certified by a delayed healthy report.

No reset endpoint or automatic retry of the effect exists. A lost poll response
without a local intent still needs operator reconciliation. Older journals
without the effect proof remain blocked rather than being retroactively certified.
Ordinary v1 receipts remain compatible; only a new recovered success adds the
optional boolean `reconciled:true`. There is no automatic rollback.

### When a reload stays partial

If a reload's outcome cannot be certified, the plan stays `partial` and its
history is kept. While that plan is `partial`, the same service does not take
another plan, so nothing new is applied on top of the unknown outcome. Other
commands on the host are held only until the plan's expiry plus ten minutes.
That clock does not close the `partial` plan, and it does not lift the hold on
the same service. The Impreza agent **keeps running**: it reports the block
locally, without restarting, reloading or retrying anything by itself. The plan
the owner reads shows `partial`. The reason string is on the server, from
`impreza-agent maintenance status`. It is not part of the plan payload.

**Releasing the block is a deliberate local step.** After checking the server,
a person with root on it runs:

```bash
sudo impreza-agent maintenance status
sudo impreza-agent maintenance release --plan <plan_id>
```

- `status` is read only. It says whether a maintenance blocks the server and
  what can end it.
- `release` never reloads, retries, deletes the journal or changes the stored
  receipt. It refuses unless the open result was already reported, no operation
  is running, and the agent, generation, unit and reload bindings still match
  the plan. The stored ingress rules must still be enforced, and every bound
  route must be healthy at that moment.
- A release does not prove that the original reload worked. It cannot repair a
  server whose root account is compromised.

**After the release**, the plan shows `released`, **not** `completed`: the
partial outcome stays in the history and is never counted as a success. A new
plan can then be prepared and approved on its own; nothing is replayed.

The Go SDK adds `PrepareManagedServiceReload(ctx, serviceID, ttlSeconds)`;
Python sync/async account resources add
`prepare_managed_service_reload(service_id, *, ttl_seconds=300)`. Preview/status are
typed; these helpers cannot approve a plan.
