# Revoke an agent's access

> Take an agent's credential away — alone, or through the two-person quarantine flow — and know exactly what stops, what finishes, and what cannot be taken back.

Canonical page: https://anectico.com/docs/agents/revoke-agent-access/


Revoking an agent's key is one command. Knowing what it does **not** reach is the part worth
reading before you need it.

## Find the key first, if you only know the agent

Revocation acts on a key id. If what you have is an agent — a name in the inventory, or a record
Anectico discovered for you — ask which credential produced its telemetry. In the product this is
**Govern → Inventory → Attribution** on the agent's row
([See every agent that is running](/docs/agents/agent-inventory)); over the API it is:

```http
GET /api/v1/assets/{assetId}/attribution
```

Every mapping in the response carries a `credentials` list naming the API keys seen producing that
agent's telemetry, with a first- and last-seen window and a sighting count, so a key used once and a
key on today's hot path are distinguishable before you act. Feed the `api_key_id` you find to the
revoke command below.

Two answers there are not keys, and both are useful rather than dead ends. `bearer_token` means a
signed-in person's session produced the telemetry — there is no key to revoke, and the control is
that person's access. `dev_org_header` means a development-only trust setting accepted it with no
credential at all; nothing can revoke that, and seeing it outside development means the setting is on
where it should not be.

An empty list means nothing was recorded for that agent, which is not the same as "it used no
credential" — see [the attribution response](/docs/reference/rest-api).

## Revoke

```bash
anectico apikey list
anectico apikey revoke <key-id> --yes
```

`anectico apikey list` shows every key's id, name, status, project binding, expiry and **last used**
— which is how you find the right one when several agents share a workspace. The `SCOPES` column is
truncated to keep the table readable; audit a key's real grant with `anectico apikey list -o json`.

In the product, the same thing lives under **Settings → Access**.

Revocation is **permanent**. A revoked key cannot be un-revoked; replacing it means minting a new
one. Revoking requires `api_key:write`, so it is not something an investigating agent's own key can
do to itself or to another.

## What stops immediately

**Every new request.** Anectico re-checks the key against its authoritative record on **every**
request — there is no cached credential and nothing to expire out of a cache — so a revoked key
starts failing at once, everywhere, across the MCP endpoint, the REST API and the CLI alike.

What the agent sees is a plain `401` with the generic body
`{"error":"unauthorized","message":"authentication required"}`. It is deliberately the **same**
answer a malformed, unknown or expired key gets: the endpoint will not confirm that a key it rejects
ever existed, so the response cannot be used to probe which credentials are real. That means the
`401` alone will not tell you *why* — check the key's status with `anectico apikey list` rather than
reading the failure.

**Every outstanding confirm token.** A preview's `confirm_token` is bound to the credential that
previewed. Once that credential cannot authenticate, the second call never runs, so nothing
previewed and not yet applied can be applied afterwards. (The token would have expired within 10
minutes regardless.)

**Every idempotency replay.** The ~10-minute window in which a retried call replays its first result
is keyed to the same credential and is equally unreachable.

## What finishes rather than stopping

Be exact here rather than reassuring.

**A short request that has already authenticated is not torn down.** Revocation is a decision about
future authentication. A request that got past the door before you revoked keeps the authorization it
was admitted with, and that authorization is not re-checked against the revocation while the request
runs. It is not a promise that the request *succeeds* — it can still fail, time out, or be cancelled
— only that revoking is not what stops it.

**Anything still delivering data when you revoke is the exception, and it does stop.** Three things can
still be sending after minutes: a download of an export's contents; a live stream — the log tail
behind **Explore → Logs → Live**, `anectico logs tail`, and `/api/v1/stream/logs`, plus a live
investigation; and an [A2A](/docs/agents/connect-a2a) streaming call, either a `SendStreamingMessage`
still working through an investigation or a `SubscribeToTask` following one. All of them re-check the
credential while they run instead of re-reading the authorization they began with. For a key that
means its current key record; for a signed-in person it means that exact sign-in session plus the
person's current membership. Revoking either stops them:

| Still running when you revoke | It stops within |
|---|---|
| An export download | a few seconds |
| A live log tail or investigation stream | 30 seconds |
| An A2A `SendStreamingMessage` or `SubscribeToTask` | 30 seconds of its next update |

Narrowing a key's scopes stops all three the same way, and so does freezing the key. A role downgrade
does the same to a signed-in person's stream when it removes anything the stream was admitted with.

`SubscribeToTask` is the one worth naming separately, because it is the only call here with no
natural end: it opens with the task's current snapshot and then follows newly committed updates for
as long as the caller stays connected. It is bounded by the same 30 seconds as everything else.

The "of its next update" in that row is exact rather than a hedge. An A2A stream is re-checked when
it has something to hand over, so a stream sitting idle mid-investigation is not interrupted the
moment you revoke — but it also delivers nothing while it sits there, and the next thing it would
deliver is refused instead. Nothing produced more than 30 seconds after the revocation reaches the
caller.

What the client sees is deliberately blunt. A download ends as a file shorter than the length it was
promised, which every HTTP client treats as a failed download. A stream ends with a final event
naming the reason — `unauthorized` when the credential no longer authenticates, `forbidden` when it
no longer holds the permission the stream needs — and marked as final, so a client stops rather than
reconnecting against a dead key. Once a response has started there is no way to turn it back into a
refusal, and a truncated answer is better than a complete one the caller may no longer have.

An A2A stream says the same three things in its own protocol, as a final JSON-RPC error in place of
the next update: `UNAUTHENTICATED` when the key no longer authenticates, `UNAUTHORIZED` when it
still authenticates but no longer holds what the stream was admitted with, and a server error when
the key could not be checked at all. Only the first is ambiguous between "your credential expired"
and "your credential was taken away" — A2A has one code for both — so the message says which, and an
agent holding a revoked key is told to obtain a new credential rather than to retry with that one.

**A signed-in person's live stream has the same bound.** Each sign-in is a separate session, so
signing out one device stops that device's live work without killing the person's other signed-in
devices. Removing the person's membership, expiring that session, or signing it out is final for
that stream. A role downgrade is checked against the current membership rather than the role copied
into the authorization at sign-in.

An authorization merely reaching its routine expiry is different: the session can still be live,
and the browser can renew it. In that case the stream ends **without** being marked final, the
dashboard signs the person back in, and it resumes where it left off. An A2A caller is told to
re-authenticate and call again. A session that was signed out or removed is marked final instead;
presenting the same session again cannot repair it.

**An export already commissioned still gets produced.** An export job records the scopes it needs at
creation time and runs on its own schedule, so revoking the key does not stop the file being written.
Downloading it is a different question, and the answer is better: the download address is an Anectico
API path that **re-authorizes on every request** rather than a link carrying its own authority, so
possessing the address grants nothing, the revoked key cannot fetch the result, and a fetch already
in progress is cut off rather than allowed to finish. Because a
key-created job records **the key** as its creator, after revocation only an organization owner or
admin can download it. Completed results remain downloadable for 24 hours.

If a running export is itself the thing you want stopped, cancel it **before** you revoke —
`cancel_export_job` works on a pending, running or retrying job, and a canceled job cannot be
resumed.

## What revocation cannot take back

**Anything that already left Anectico.** An action with `effect_scope: external` caused something
outside your workspace that no credential change retracts: a ticket filed or closed in your tracker,
an invitation emailed, a test message delivered to a real channel, an incident that paged every
enabled channel. Ask the server for the complete current list rather than trusting a fixed one here:

```json
{"name": "list_write_actions", "arguments": {"effect_scope": "external"}}
```

**Anything already deleted.** `delete_log` and `delete_trace` are durable deletions. Nothing restores
them, and neither does revoking the key that ran them.

**Anything already read.** Data the agent retrieved is in its context, its transcript, and possibly
its host's logs. Revocation ends future access; it does not un-read a person's profile properties or
an agent-run transcript. If the concern is what was read rather than what was changed, the control
that mattered was the scope the key was minted with — see
[Scope recipes for agent jobs](/docs/agents/scope-recipes).

**Anything a notification already carried.** A message delivered to Slack, an email, or a webhook
went to a destination with no permissions of its own. Narrowing or removing a credential does not
change what was delivered; change the alert rule that feeds the destination.

## Contain an agent

Revoking a key you already found, above, is the right tool when one credential needs to stop and
you are the one person that decision needs. **Quarantine** is the tool for the rest: you want to
stop writes without losing the ability to bring the agent straight back, you want to act on an
asset or an undeclared agent rather than a key id you've already looked up, or your organization
requires that disabling a production credential is never a one-person decision.

In the product, containment lives at **Govern → Containment**. Requesting and approving are two
separate controls there, and the page refuses to offer you the approval of a request you raised
yourself.

### It always takes two people

A containment is two acts by two different principals. **Requesting** asks for it and does nothing
else — no credential is touched by a request alone. **Approving** is the act that actually disables
something, and the platform refuses to let the same principal do both: the database itself rejects
an approval whose approver matches its own requester, so this cannot be worked around by a script
or an automation running under one shared credential.

`quarantine:request` is granted to every role except viewer, deliberately — sounding the alarm
should not require finding an admin first. `quarantine:approve` is owner/admin-only, because
approving is what disables a production credential; the same scope is required to lift a
containment afterward. Withdrawing a request nobody approved is the one act that sits with the
requester: see [Withdraw a request nobody approved](#withdraw-a-request-nobody-approved). See
[Permission scopes](/docs/reference/permissions#organization-ai-and-agents) for both.

### Three actions, and what each one actually does

| Action | What it does | Reversible? |
| --- | --- | --- |
| `freeze_writes` (default) | Strips every write-shaped scope from each credential involved, keeping only the scopes that end `:read`. The original scope set is stashed so a lift restores it exactly. | Yes — the only reversible action |
| `revoke_credentials` | Revokes each credential outright — the same durable revocation [described above](#revoke). | **No.** There is no un-revoke |
| `kill` | Freezes and then permanently revokes every credential in the approved set. When the target resolves to an agent asset, it also installs a standing ban on minting a declared credential for that agent; a direct API-key target has no agent on which to install that ban. | Only the freeze half; the ban lifts with the containment |

Read `freeze_writes` literally: it removes every scope that is not read-shaped. A typical SDK
ingest key holds only `ingest:write` and nothing else, so freezing it reduces that key to **zero**
scopes. **A frozen ingest key's telemetry stops arriving.** That is the point of freezing an agent's
telemetry credential, not a side effect to route around — if you need the agent's data to keep
flowing while you investigate, freezing that key is the wrong action.

`revoke_credentials` calls the exact same durable revoke as [the CLI command above](#revoke) — same
`401` on the very next request, same permanence. Everything in
[What stops immediately](#what-stops-immediately), [What finishes rather than stopping](#what-finishes-rather-than-stopping),
and [What revocation cannot take back](#what-revocation-cannot-take-back) is equally true of a
quarantine that revokes.

### `kill` and its third clause — read this before you rely on it

**`kill` also stops a new key being minted for the agent, but only for keys that say which agent
they are for.**

A key can be minted with a **declaration**: the id of the agent it is being created for. Pass
`--agent` to `anectico agent bootstrap` or `anectico apikey create`, or send `"agent_asset_id"` in
the body of `POST /api/v1/account/api-keys`. A declared key gets two things an undeclared one does
not:

- while a `kill` containment is live on that agent, minting another key for it is **refused** — a
  `400` whose `details.reason` is `AGENT_MINT_BANNED` and whose `details.asset_id` names the agent,
  so you can find the containment that refused you;
- a containment can reach it **before it has ever been used**. Anectico normally learns which key
  belongs to which agent by watching telemetry, so a key you minted and have not deployed yet is
  invisible to that. A declared key is not.

The declaration has to name a **real agent in the same project as the key**. An id that matches
nothing, one belonging to another project, or one naming something that is not an agent is refused
with a `400` before any key is created — so a declaration is a fact about your inventory rather than
a string you typed. Two consequences worth planning around: declare an agent only once it appears in
`anectico fleet list` (a brand-new agent has no id until its first telemetry arrives, which is why
`anectico agent bootstrap` warns rather than refusing when `--agent` is omitted), and an
**org-wide** key cannot carry a declaration at all, because an agent belongs to exactly one project.

A key minted for that agent **while the kill is being applied** does not slip through. The ban and
the list of keys it acts on are taken as one act, so a mint either lands before the ban — in which
case the same `kill` revokes it, and its row in the containment says `credential_minted_during_kill`
— or it arrives after and is refused.

**A key that declares no agent is outside both.** Anybody in your organization who can create API
keys can still create a plain one and give it to the agent you killed, and nothing refuses that
mint. The first telemetry from that key supplies the missing link: Anectico then raises a critical
review signal naming the non-secret key id, the killed agent, the active containment, and the first
and latest sighting times. It raises one row per key and containment, not one per span.

That signal appears in the review queue, sends opening and clearing notifications, and can be
targeted by an alert rule using the `review_signals` source. It contains nothing and revokes nothing;
a person still decides whether to revoke the key. Repeated sightings do not send another opening.
Revoking it or lifting the named containment closes the row. The window before first telemetry remains real, so if your recovery plan
depends on "nobody can re-provision this agent", the process control is still yours: **mint agent
keys with `--agent` so the ban can bind, and treat `api_key:write` as the permission that can undo a
containment.**

Every `kill` tells you which case you are in. Its enforcement points include one for the mint path:

| What you see | What it means |
| --- | --- |
| `mint_ban_declared_only` | The ban is in force for keys that declare this agent. It cannot stop an undeclared key. |
| `mint_ban_no_asset` | **No ban was installed at all** — the containment named an API key directly, or an agent Anectico has not yet built an inventory entry for. |
| `enforcement_failed` | The ban could not be installed. [Re-enforce the containment](#if-part-of-a-containment-failed-re-enforce-it) — do not lift it. |

Lifting the containment restores minting. If two containments name the same agent, both have to be
lifted — and the lift tells you when the ban survives because another one still holds it. A lift of
a containment whose ban was never installed says `nothing_to_restore` rather than
`quarantine_lifted`, so the record keeps saying that the third clause was never in force.

The inventory lifecycle follows the same cover-set rule. Two different targets can resolve to the
same agent; lifting either one leaves that agent `quarantined` while the other containment is active,
and the last lift restores the lifecycle state the agent had before the first containment began.

**If a lift fails partway, run it again.** Lifting is idempotent: a repeated lift on a containment
that has already been released finishes whatever the first attempt did not, rather than refusing
you. Retrying is always the right first move.

### If part of a containment failed, re-enforce it

A containment is recorded before anything is applied, so an approval can succeed while one of its
effects does not — a credential that could not be reached, or a mint ban that could not be
installed. The containment then reads `active` with a `failed` row inside it, which is the truth and
not a bug.

**Do not lift it to fix it.** Lifting restores the credentials and re-opens the mint path, which for
a `kill` re-opens the agent in order to repair the thing that was meant to keep it shut. Re-enforce
instead:

```bash
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/reenforce"
```

It re-runs only what is recorded as failed, under the approval the two principals already made — it
approves nothing new and needs no second person. `retried` in the response says how many things were
re-attempted; `0` means there was nothing left to repair, which is a success rather than an error. It
takes the same permission as approving and lifting.

### What a containment names

A target is one of three things, not always a key you already hold:

- an **asset id**, if the agent is in your inventory — resolves to every credential that has
  produced that asset's telemetry
- an **observation key**, for an agent nobody declared — the
  [attribution lookup](#find-the-key-first-if-you-only-know-the-agent) above returns this
- an **API key id** directly, when you already know exactly which credential to stop

The request body must supply **exactly one** of `asset_id`, `observation_key`, or `api_key_id`.
Supplying none or supplying several is a `400`; Anectico never chooses one by priority. `action` may
be omitted, in which case it defaults to `freeze_writes`. If it is present, it must name
`freeze_writes`, `revoke_credentials`, or `kill`; a blank or unknown spelling is a `400` and creates
no containment request.

An API key id is checked against your organization's keys when the containment is **approved**, and
against the **project** the containment is scoped to. A well-formed id that names no key of yours —
one wrong character, a key belonging to somebody else, or one belonging to a different project of
your own — is refused at that point with a `404`, rather than producing a containment that reports
success over a credential it should not reach. Revoking is permanent, so this check runs before the
approval is signed. A key created with `org_wide` belongs to no project and stays reachable from any
of them. The same rules apply to an asset id outside the scope you named.

### Request, approve, verify, lift

```bash
# 1. Request — any member or developer can raise this
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines" \
  --data '{
    "asset_id": "asset_9f2c...",
    "action": "freeze_writes",
    "reason": "Agent is retrying a failing tool call in a tight loop"
  }'
```

A successful request answers `201`. Its `quarantine.quarantine_id` is what the next three calls act
on, and `quarantine.status` reads `"pending"` — nothing has been enforced yet.

`request_id` is yours to choose, and re-sending the same one returns your existing request instead of
opening a second containment. It is scoped to the person who used it first: if somebody else in your
organization already used that id, you get a `409` telling you the id is taken — and nothing about
their request. Two people running the same runbook need two ids, or one of them will be told to pick
another.

```bash
# 2. Approve — a DIFFERENT principal, holding quarantine:approve. Reusing the
# requester's own key here is refused: the platform can tell from the credential
# that made the request, and rejects an approval that would make the two-person
# rule a formality.
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/approve"
```

```bash
# 3. Verify — check what actually happened
curl --fail-with-body \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID"
```

```bash
# 4. Lift — when the incident is over
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_APPROVER_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/lift" \
  --data '{"reason": "Root cause fixed, agent redeployed with a corrected retry budget"}'
```

Lifting also takes `quarantine:approve` — restoring capability to an agent someone decided to stop
is treated as the same grade of decision as stopping it in the first place. A lift restores
whatever a freeze reduced. It does **not** undo a revoke: see
[Three actions](#three-actions-and-what-each-one-actually-does) above, and mint a replacement
credential the normal way once you've lifted.

**A lift frees only what no other containment is still holding.** One key often serves several
agents, so two containments raised against two different agents can land on the same credential.
Lifting one of them leaves that shared credential frozen while the other is still active, and says
so: the credential appears under `quarantine.credentials` with `detail_code`
`held_by_another_quarantine`. Lift the other containment and the credential comes back — whichever
lift is the last to let go is the one that restores it. Without that rule, lifting the first
containment would hand the agent its capability back while the second one still reported it
contained.

Two people lifting two overlapping containments at the same moment is safe. Whichever of the two
ends up last still releases the shared credential, so simultaneous lifts cannot leave an agent
frozen by a containment that no longer exists.

To see everything currently open or historical, `GET /api/v1/quarantines?status=pending` (repeat
`status` for more than one value) lists across your organization, newest first.

### Withdraw a request nobody approved

Not every request should end in a containment. An alarm raised on a hunch that turns out to be a
deploy, a runbook fired twice, a request overtaken by the agent being taken offline anyway — all
leave a `pending` row, and a pending row is not inert: **while a containment is pending, no second
one can be raised against the same agent.** Leaving it there means the next person who genuinely
needs to contain that agent is refused.

```bash
curl --fail-with-body \
  -X POST \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  -H "Content-Type: application/json" \
  "https://app.anectico.com/api/v1/quarantines/$QUARANTINE_ID/withdraw" \
  --data '{"reason": "The retry storm was a bad deploy, not the agent"}'
```

**Withdrawing your own request takes only `quarantine:request`** — the scope you used to raise it.
That is deliberate and it is the one place the two containment scopes are not symmetric: a pending
request has enforced nothing, so retracting it hands capability back to nobody, which is the thing
`quarantine:approve` is owner/admin-only to protect. Requiring an admin to clear a row that never had
an effect is how such rows come to be left alone.

**Withdrawing somebody else's takes `quarantine:approve`.** Retracting another person's request
suppresses a containment before a second principal has had the chance to consider it, which is the
same decision as refusing one.

Three things to expect:

- A withdrawal reaches **no credential**, because a pending request never touched one. Nothing is
  frozen, released, revoked or restored by it.
- It only works on a `pending` request. An approved containment is ended with a **lift**, which
  restores credentials; a withdrawal on anything else is refused with `400`.
- The withdrawn request keeps its `request_id`, and re-sending that id returns the withdrawn record
  rather than opening a new containment. **Raising the request again means choosing a new
  `request_id`.**

Like a lift, a withdrawal requires a `reason`, and the record stays: `status` reads `"withdrawn"`,
with `withdrawn_by`, `withdrawn_at` and `withdraw_reason`. "Somebody asked to stop this agent and
then thought better of it" is a fact an incident review wants, and deleting the row would lose it.

### Reading the status

`quarantine.status` is `"pending"` (requested, nothing enforced), `"active"` (approved and
enforced), `"lifted"` (released after having been enforced — the record stays, because "this agent
was stopped for six hours last Tuesday" is exactly the fact an incident review needs), or
`"withdrawn"` (retracted before anyone approved it, so it was never enforced at all).

`"lifted"` and `"withdrawn"` are separate on purpose. Both are endings, and they say opposite things:
one agent was stopped and let go, the other was never stopped. A count of your containments should
not include the ones that never happened.

A quarantine that has ended also carries **why**: `lift_reason` on a lifted one, `withdraw_reason` on
a withdrawn one, beside the `lifted_by`/`lifted_at` and `withdrawn_by`/`withdrawn_at` pair.

Each credential a quarantine resolved to is reported individually, under `quarantine.credentials`,
with its own state — one containment can legitimately freeze one key and find a second already
revoked, and a single rolled-up answer would misreport the pair. **`verified` means the platform's
own credential store now denies the credential** on its very next request — that is a fact about
the credential, not a promise that no more telemetry has arrived, because confirming an absence
would mean waiting and guessing how long is long enough.

**`unknown` is not a failure — it is an honest answer to a question containment cannot always fully
close.** You will see it when:

- some of the target's recorded telemetry carried no credential the platform can revoke at all — a
  signed-in person's dashboard session, or a sighting recorded before this feature existed to track
  which credential produced it
- the target resolved to no credentials at all, in which case reporting `verified` would claim to
  have revoked nothing while sounding like containment succeeded
- a credential this containment named was **already frozen** by an earlier one, so this containment
  changed nothing about it (`detail_code` `already_contained`). The credential is contained — and it
  stays contained until every containment naming it has been lifted — but the effect belongs to the
  earlier one, and claiming it here would be this containment taking credit for another's work
- the credential no longer exists in your organization (`detail_code` `no_such_credential`), which
  is a different fact from having contained it

`quarantine.unenforceable_sightings` tells you how many sightings fall into the first case. A
non-zero count is not a bug report: it means some of what this agent did did not arrive on anything
the platform can take away, and the honest status for that is `unknown`, never a false `verified`.

### A contained key cannot be edited around

While a key is frozen, its scopes cannot be changed by the ordinary key-editing route: a
`PATCH /api/v1/account/api-keys/{id}` that carries scopes is refused with a `400` whose message
names the containment. Renaming still works.

This is deliberate, and it matters because the two authorities differ. Editing a key's scopes is
something every member and developer can do; approving and lifting a containment is owner/admin
only. Without the refusal, anyone who could edit a key could hand a stopped agent its write scope
back on the next request — with nothing linking that act to the containment somebody else approved.
It also keeps the lift honest: the original scope set is stashed when the freeze happens, so a
change made while frozen would either be overwritten by the lift or, worse, silently re-granted by
it. Lift the containment first, then edit.

A frozen key is visible as frozen. `GET /api/v1/account/api-keys` reports `frozen: true` with a
`frozen_at` timestamp for every contained key, so a key whose scope list has shrunk is explained
rather than mysterious. It is not the same as `revoked`: a frozen key still authenticates, and a
revoked one is gone for good.

## Review what the agent did

Read the trail under **Settings → Audit**, which needs `audit:read` — a scope owners and admins hold.
Filter by resource, by action, or by **result**.

Every mutation that reached its handler is there, and not only the ones that worked. Each record's
result is one of:

| Result | What it means | Did anything change? |
|---|---|---|
| `success` | The change was applied | Yes |
| `denied` | Refused — scope, confirmation, or policy | No |
| `previewed` | A confirm-gated call returned a preview and stopped | No |
| `throttled` | The write budget refused the call | No |
| `error` | The call reached the change and failed, or ended by an unknown path | Unknown — cross-check |
| `unknown` | The platform could not classify the outcome | Unknown — cross-check |

Alongside the result each record carries the resource, the action, the tool name, and the affected
object. Filtering on `denied` is usually where a review starts: a `denied` beside a `previewed`
beside a `throttled` is the shape of an agent trying things it was not scoped for.

`unknown` never means success. It means the platform declined to guess, which it does rather than
record an outcome it cannot vouch for — an empty list of refusals must mean there were none, not
that the question could not be answered.

### Who gave this agent its access, and when

Reviewing what an agent *did* is half the question. The other half is what it was ever *allowed* to
do, and who decided that:

```http
GET /api/v1/account/audit-events?target_api_key_id={id}
```

That returns the key's whole grant history and nothing else — issued, scopes changed, revoked,
newest first. It matches the key that was *changed*, never the key that did the changing, so a key
used to administer other keys does not collect their history.

Every one of those records carries the scope set in `metadata`:

- `scopes` — what stands after the act. On the issue it is what was granted; on the revocation it is
  what the credential could do at the moment it stopped working, which nothing can tell you
  afterwards. An empty value means nothing was granted, which is different from the field being
  absent.
- `previous_scopes` — the set a change replaced, present only on a scope change. A rename replaces
  nothing and carries neither, so a record with both is always a real widening or narrowing.

`event_type` narrows it further — `api_key_created`, `api_key_updated`, `api_key_revoked` — and
composes with the key filter, so "every time this agent's scopes changed" is one request.

The key listing itself answers who issued the credential: `user_id` for a person,
`created_by_api_key_id` when a key issued a key, and `created_by_state` saying which of those
applies so an empty `user_id` is never ambiguous. `orphaned_user` there means a person issued it and
their record is no longer available — the attribution was lost, and the record says so rather than
looking like a key nobody issued.

Revocations always name their actor. A key revoked because its project or organization was deleted
records the platform as the actor and names the deletion under `metadata.via`, so a credential that
stopped working without anybody touching it is explained rather than mysterious.

Two things the trail will not tell you, so plan the review around them:

- **A call refused before it was dispatched is not in it.** An action the key was never scoped for,
  or one excluded by the profile the agent connected with, is refused without running, and there is
  nothing to record. Absence of a record is not evidence the agent never asked.
- **The record is written beside the change, not inside it.** A record that fails to persist does not
  undo the change it describes. For anything consequential, cross-check.

The cross-check is the receipts the agent itself received. Every committed write returned the
operation, the outcome, what changed, the targets the platform confirmed, and both `effect_scope` and
`destructive` — so a transcript says how far each change reached without re-reading the catalog. A
receipt also carries a mutation id and a commit time **when the platform reported them**, and leaves
them null when it did not; a null there means "re-read to find out", never "nothing happened".

## Rotate instead, when nothing is wrong

If the key is simply old or over-scoped, replace before you revoke so the agent never loses its
footing:

```bash
anectico agent bootstrap --project <project-id> --host claude --scope-profile investigate
eval "$(anectico agent key env --export)"
anectico apikey revoke <old-key-id> --yes
```

Re-bootstrapping the same profile **replaces** the stored secret and the `anectico` entry in the
host's MCP configuration rather than accumulating a second one; every other entry in that file is
written back untouched.

Two habits make this rarely urgent:

- **Give every key an expiry.** `anectico agent bootstrap` defaults to 30 days, and `--expires-in`
  accepts anything up to 365 days. An expired key fails exactly like a revoked one.
- **One key per purpose.** A developer's local agent, CI, a shared support workflow and production
  operations should not share a credential — otherwise revoking one of them takes down all four, and
  the audit trail cannot tell them apart.

## Withdraw a delegation, without touching the credential

Revoking a key stops a *credential*. Withdrawing a delegation records that one **authority
relationship** should no longer exist — "this agent may no longer act through that tool for that
purpose" — and leaves the key alone.

The two are different acts and you often want both, in that order. A delegation is identified by an
edge id from the authority graph:

```http
GET  /api/v1/authority-edges?from_principal={principal}
POST /api/v1/authority-edges/{edgeId}/revoke
```

The POST body carries one field, `reason`, and it is **required** — a withdrawal that records who
and when and nothing about why is the one a reviewer six months later cannot act on. Requires
`agents:write`. Who withdrew it is taken from your credential and cannot be set in the body.

Four things are worth knowing before you rely on it:

- **It is not a delete.** The edge stays, with its full sighting history — how often the delegation
  was exercised and when it last was. Withdrawing authority is not a claim that it was never used.
- **It disappears from the default read.** `GET /api/v1/authority-edges` hides withdrawn delegations,
  because the question that read answers is "what authority exists now". Pass
  `include_revoked=true` to see the history.
- **Repeating the call is safe.** The response says `already_revoked: true` and returns the
  withdrawal that stands — the first reason and the first instant, not yours.
- **Using a withdrawn delegation does not restore it.** If an action later exercises it anyway, the
  delegation stays withdrawn and a **review signal** is raised for a human, visible at
  `GET /api/v1/review-signals?status=open`. A withdrawal cannot be undone by ignoring it. This
  records the violation; it does not stop the action — for that, revoke the credential or contain
  the agent.

## What is not a revocation

**A profile is not a revocation.** Connecting with `?profile=investigate` really does stop that
connection invoking a mutation — but the caller chose the parameter, the key still holds every scope
it was minted with, and the next connection without it is served all of them again. Only revoking or
re-scoping the key changes what the credential can do.

**Withdrawing a delegation is not a revocation of the credential.** It records that an authority
relationship should not exist and makes any further use of it visible; the key still works and the
agent can still act. Do both when you mean both.

**Removing the MCP server from a host config is not a revocation.** The secret is still valid
wherever else it has been copied — the environment variable, a CI secret store, a shell history. Revoke
the key.

- [How agent access is enforced](/docs/agents/security-model)
- [Scope recipes for agent jobs](/docs/agents/scope-recipes)
- [Authentication and API keys](/docs/reference/authentication)
- [Permission scopes](/docs/reference/permissions) — `quarantine:request` and `quarantine:approve`
- [REST API](/docs/reference/rest-api) — the agent-attribution and containment endpoints in full
- [Memory provenance and quarantine](/docs/agents/memory-provenance) — the sibling workflow that
  labels a memory *item*, not a credential
