> Markdown version of https://agledger.ai/docs/operations/offboarding/
> Full index of this site for AI assistants: https://agledger.ai/llms.txt

# Offboarding runbook

Three scenarios: one agent, one org-admin key that may be compromised, and an identity provider
whose trust you are withdrawing. They share one rule: **deactivation revokes credentials and
deletes nothing.** Every record the party wrote stays on the chain, still signed, still verifying.
The last section says what to tell whoever asks about that.

Order matters more than any single call. Every step here is reachable from the
[API reference](/api/); which one to run first, and what is still live between steps, is not.
Revoking certs before you disable the IdP row leaves a token exchange open that mints replacements
behind you.

An org-admin key can do the agent and API-key work, scoped to its own org, on `admin:keys`. Four
things need a platform key: reactivating an account, revoking ephemeral certs, everything under
`/v1/admin/trusted-issuers`, and `POST /v1/admin/provisioning/reload`. The org-wide webhook listing
needs `admin:system` rather than `admin:keys`. Each step below says which it is.

## 1. One agent

An agent is leaving: the workload is being retired, the integration is being switched off, or its
credentials are suspected exposed.

```bash
API=https://agledger.example.com
KEY=<admin or platform key>
AGENT=<agent uuid>

# 1. Stop it authenticating. One call revokes every active API key the agent
#    owns, refuses its existing ephemeral certs, and refuses new cert exchanges.
#    Refused with 403 if this agent holds an admin-role key that is your org's
#    only usable admin door (an agent CAN own an admin key): mint or restore a
#    second admin credential for the org first.
curl -sX POST "$API/v1/admin/agents/$AGENT/deactivate" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"reason":"workload retired 2026-09-06"}'

# 2. Confirm nothing is left holding a credential for it.
curl -s "$API/v1/admin/api-keys?ownerId=$AGENT" -H "Authorization: Bearer $KEY" \
  | jq '[.data[] | select(.isActive)] | length'   # expect 0
```

Step 1 is the whole credential story for an agent. It sets `deactivatedAt`, bulk-revokes the
agent's active API keys, and every credential bound to the agent is then refused at authentication
with 403 `ACCOUNT_DEACTIVATED`, including an ephemeral cert issued before you ran it. The agent can
no longer exchange an IdP token for a new cert at `POST /v1/auth/oidc/cert` either. Certs are
refused on the next request they make: the deactivation is broadcast to every replica once it
commits, and a replica whose listener is down heals on the auth-cache TTL instead, about 30
seconds.

It also closes the door from the other side. A deactivated agent can no longer be *named* as a
party on new work, by anyone. `POST /v1/records` carrying it in `principalAgentId` or
`performerAgentId` is refused with 422, and `POST /v1/records/bulk` fails that one item inside its
207 rather than the batch. Sub-records delegated under a record the agent performs are refused the
same way, so an in-flight delegation chain cannot be extended through it. The refusal carries
`detail` naming the agent and the timestamp it was deactivated at. Expect those 422s from the
agent's counterparties, not from the agent, and read them as the offboarding working. Records that
already name it are untouched and keep their signed attribution.

**What stays live after deactivation, and how to close each one.**

*Webhook subscriptions the agent owned keep delivering.* A subscription belongs to itself, not to
the credential, so an agent-owned subscription carries on posting to whatever URL it names, for
records that agent is a party to. Find them on the health listing, which is the org-wide view and
the one that reports each subscription's owner. `GET /v1/webhooks` already returns an org-admin's
own subscriptions alongside its org's agents' ones, but it is not the tool for this: the response
strips `ownerType` and `ownerId` from every row, so you cannot tell which ones are the agent's. The
health listing is the surface that names the owner.

```bash
curl -s "$API/v1/admin/webhooks/health" -H "Authorization: Bearer $KEY" \
  | jq --arg a "$AGENT" '.data[] | select(.ownerType=="agent" and .ownerId==$a) | {id, url, managedBy}'

curl -sX DELETE "$API/v1/webhooks/<webhookId>" -H "Authorization: Bearer $KEY"
```

The health listing needs `admin:system`; a key minted with a narrower profile can still delete and
pause, it just cannot enumerate. **Run the delete with an org-admin key, not a platform key.** An
org-admin key can delete, pause or rotate any subscription in its own org, the agent-owned ones
included. A platform key reaches only subscriptions it created itself and gets 403
`WEBHOOK_NOT_OWNED` on anything else; from a platform key the lever is
`PATCH /v1/admin/webhooks/{webhookId}/circuit-breaker` with `state: "open"`, which stops deliveries
without touching the row. Pause instead of delete (`POST /v1/webhooks/{webhookId}/pause`) if you
might bring the agent back: deliveries stop, the subscription and its secret survive, and events
arriving during the pause are dropped rather than queued.

An agent-owned row comes from [provisioning YAML](/docs/install/provisioning/)
(`ownerType: agent`): no agent scope profile carries `webhooks:manage`, so no agent registers one
through the API. Whether the directory still manages it decides how you stop it.

**Read `managedBy` before you delete.** The health listing and `GET /v1/webhooks/{webhookId}` both
carry it: `"provisioning"` means the provisioning directory owns the row, `null` means the API does.
A provisioning-managed subscription refuses `DELETE /v1/webhooks/{webhookId}`,
`PATCH /v1/webhooks/{webhookId}` and `POST /v1/webhooks/{webhookId}/rotate` with 409
`PROVISIONING_MANAGED`, the same refusal a provisioning-managed agent gives `deactivate`. The reason
is that the row would come back: a delete here deactivates rather than removes, and every reconcile
sets `isActive` back to true and rewrites the filters, `format`, `signingAlg` and the secret from
the file. The `recoveryHint` on the 409 names the directory and the pause call.

**Pause is the API-side stop that holds on a managed row.** `POST /v1/webhooks/{webhookId}/pause`
is accepted where the three verbs above are refused, and it survives a reload: `isPaused` is the one
configuration column the reconciler does not write. Deliveries stop, the row stays active and
declared, and `POST /v1/webhooks/{webhookId}/resume` puts it back. It is also the stop your
org-admin key can make on its own, since reconciling the directory is platform-only.

To stop a managed subscription for good, take it out of the YAML and prune, the same way you prune
an agent below. `url` is how the directory identifies a subscription, so editing the URL in the
declaration does not repoint the row: it declares a second subscription and leaves the first one
delivering until a prune pass removes it.

*A provisioning-managed agent refuses step 1 outright.* If the agent was declared in provisioning
YAML, `deactivate` answers 409 `PROVISIONING_MANAGED`: config-as-code owns the row, and letting an
API call contradict the file would leave the two disagreeing until the next reconcile. Prune
through the reload route is the door that owns the row, and it does the whole job in one step.

`PROVISIONING_PRUNE` is Server configuration, read once at boot, not something you set in front of
the curl. Turn it on where the rest of the Server's environment lives and restart, then reconcile
with a platform key (`reload` is platform-only; an org-admin key is refused 403). Turn it back off
once the prune has run: it is a blanket setting, not scoped to this one offboarding, and leaving it
on means the next unrelated YAML edit anywhere in the directory can silently deactivate whatever it
drops.

```bash
# 1. remove the agent from the provisioning YAML
# 2. PROVISIONING_PRUNE=true in the Server environment, then restart it
#    (a boot with prune on already reconciles; the call below is for a later pass)
curl -sX POST "$API/v1/admin/provisioning/reload" -H "Authorization: Bearer $PLATFORM_KEY"
```

**Check the reload response first.** A load error anywhere in the provisioning directory, in any
file, suppresses pruning for the whole run: `pruned` reads `0`, the departing agent's keys stay
active, and the only signal is `pruneSuppressed: true` on `GET /v1/admin/provisioning/status` or a
`resource: "config"` entry in `errors[]`. Fix the reported load errors and reconcile again, or fall
back to the explicit deactivate call.

Prune revokes the agent's API keys, releases management (`managedBy` becomes null), and sets
`deactivatedAt`. All three happen together, with one exception: a key the sweep would otherwise
revoke is left active when it is the install's last usable platform key, because revoking it would
leave nothing able to mint a replacement, and that key is named in the reload response's `errors[]`
instead. Otherwise the IdP-token door closes along with the key door: a pruned agent can no longer
exchange an `oidcIss` / `oidcSub` binding for a fresh cert. Webhook
subscriptions the YAML declared for that agent are pruned in the same pass, which is the one path
that closes the delivery hole above without a second call. Without `PROVISIONING_PRUNE=true`, removing the
declaration stops the reconciler updating the row but leaves `managedBy` set, and the row stays
locked out of the admin verbs.

Running the explicit deactivate call afterward is optional, not a second required step. Prune
already released `managedBy`, so it is no longer refused with `PROVISIONING_MANAGED`, and it is not
a duplicate error either: the account is already deactivated, so it revokes any key minted between
the prune and the call and leaves the first deactivation's timestamp alone. Worth running only if
time passed between the two; back to back it has nothing left to do.

*Reactivation is platform-only, and does not undo step 1 in full.*
`POST /v1/admin/agents/{id}/reactivate` clears the flag, and new records may name the agent again.
It does not restore the keys that were revoked; mint replacements at `POST /v1/admin/api-keys`. An
org-admin key is refused here with 403, so the operator who ran the offboarding cannot quietly
reverse it. The call is idempotent: on an already-active account it returns 200 with
`wasDeactivated: false`.

## 2. One org-admin key, possibly compromised

Someone holding an admin key has left, or a key has leaked. The key is the unit here, not the
person: AGLedger has no user directory, and a key is what authenticates.

```bash
# 1. Revoke it. One-way for an admin caller: you can turn a key off, and only a
#    platform key can turn one back on.
curl -sX PATCH "$API/v1/admin/api-keys/<keyId>" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"isActive": false, "reason": "holder offboarded"}'

# 2. Find anything else that key minted or rotated into, and decide about each.
curl -s "$API/v1/admin/api-keys?orgId=<org>&isActive=true" -H "Authorization: Bearer $KEY" \
  | jq '.data[] | select(.createdByKeyId=="<keyId>" or .rotatedFromKeyId=="<keyId>")
        | {keyId, ownerType, ownerId, label}'
```

Sending `isActive: true` on a revoked key from an admin credential is refused with 403
`PLATFORM_REQUIRED`. Issue a replacement instead, or use a platform key to restore that one.

Step 2 is the step people skip. An admin key mints other keys, and revoking it does not revoke what
it issued: those keys keep working, attributed to the key that created them. A holder who rotated
the key at `POST /v1/auth/keys/rotate` left a replacement that names it in `rotatedFromKeyId` rather
than `createdByKeyId`, which is why the filter reads both. The listing is paged, so follow
`nextCursor` while `hasMore` is true. Treat a compromised admin key as a compromise of everything it
minted while it was out of your control, and use the creation timestamps to bound the window.

For several at once, hand the ids from step 2 to bulk-revoke, up to 100 at a time. There is no
filter for "keys this key minted", so the id list is the way; `ownerId`, `role`, `createdBefore`,
`lastUsedBefore` and `neverUsed` (which requires `createdBefore` beside it) are the filters it does
take. Every one of them is ANDed with your own org, so an admin key cannot reach beyond it:

```bash
curl -sX POST "$API/v1/admin/api-keys/bulk-revoke" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"keyIds":["<id>","<id>"],"reason":"minted by an offboarded admin key"}'

# or by owner, when you are retiring everything one agent holds
curl -sX POST "$API/v1/admin/api-keys/bulk-revoke" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"ownerId":"<agent uuid>","reason":"holder offboarded"}'
```

A sweep that would leave your org with no active admin key and no enabled trusted-issuer row able
to carry an admin bearer is refused with 403 before anything is revoked, because minting a
replacement needs `admin:keys` and completing the sweep would leave recovery to whoever holds the
platform key. Mint the replacement first, then re-run. An org whose recovery path is SSO is not
blocked, since an admin OIDC bearer authenticates with no API key row at all.

If the departing admin authenticated through OIDC rather than a minted key, there is no key to
revoke: the credential is the IdP's. Disable the person at the IdP, or use the levers in section 3.

Afterwards every swept row carries `revokedAt`, `revokedByKeyId` and the `reason` you sent as
`revocationReason` in `GET /v1/admin/api-keys`, so the sweep reads back later without opening the
chain.

Two things are worth checking on the way out. `GET /v1/admin/api-keys?neverExpires=true` lists the
keys nothing will ever retire on your behalf, and `lastUsedBefore=<date>` or `neverUsed=true` lists
the dormant ones, which is the inventory an offboarding tends to surface. And both
`API_KEY_DEFAULT_LIFETIME_SECONDS` (90 days by default) and `API_KEY_MAX_LIFETIME_SECONDS` bound
only the expiry a mint or rotation sets, not a key that already has one. One case does reach an
existing key: a provisioning reconcile monotonically extends, never shortens, the `expiresAt` of a
key it still declares, each time it reconciles inside the current window. A key nothing mints,
rotates or re-declares keeps exactly the expiry it has.

## 3. An IdP whose trust you are withdrawing

The customer is switching identity providers, or an IdP is compromised. Two levers, and a full
response uses both, in this order:

```bash
# 1. Stop new credentials. PATCH first: revoking certs while the row is still
#    enabled leaves a token exchange open that mints replacements behind you.
curl -sX PATCH "$API/v1/admin/trusted-issuers/<id>" \
  -H "Authorization: Bearer $PLATFORM_KEY" -H 'Content-Type: application/json' \
  -d '{"enabled": false}'

# 2. Kill the credentials already out there. Platform-only.
curl -sX POST "$API/v1/admin/trusted-issuers/<id>/revoke-certs" \
  -H "Authorization: Bearer $PLATFORM_KEY"
```

`enabled: false` stops the row matching, so no new token exchange succeeds against it.
`revoke-certs` revokes every currently-active ephemeral cert that issuer minted. They are refused on
the next request they make, because the revocation is broadcast to every replica once it commits; a
replica whose listener is down heals on the cert-auth cache TTL instead, about 60 seconds. Neither
lever implies the other, which is why the order matters. Certs that had already expired or been
revoked are left alone and are not counted.

To revoke one cert rather than all of them: `POST /v1/admin/ephemeral-certs/{id}/revoke`,
platform-only, idempotent.

Disabling a row leaves it in place, which is what you want during an incident: the registration is
still there to read, and re-enabling is a PATCH.

**Do not plan on deleting it.** `DELETE /v1/admin/trusted-issuers/{id}` is refused with 409
`ROW_HAS_DEPENDENT_RECORDS` for any issuer that has ever minted an ephemeral cert, permanently: the
cert ledger keeps the issuer row so the certs it signed stay attributable. `enabled: false` is the
retirement, not a step toward one. Delete only ever succeeds on an issuer that never issued
anything. A provisioning-managed row refuses the delete for its own reason, 409
`PROVISIONING_MANAGED`; take it out of the YAML, or `PATCH` the row with `managedBy: null` to hand
it to the admin API first.

An admin bearer from that IdP is covered by step 1: with the row disabled, the bearer no longer
resolves to an admin identity.

To withdraw one subject rather than the whole issuer, `PATCH` the row's `subjectAllowlist`: a row
that carries one admits only the `sub` values it lists, and a token from any other subject is
refused with 401 `subject_not_allowlisted`, on the admin bearer and at the cert exchange. The list
binds tokens presented from then on, so end certs already minted with the single-cert revoke above,
or with `revoke-certs`, which ends every cert the issuer minted. On a row that has no list yet, the
list you send has to name every subject that should keep working.

## What stays, and why

Every record the offboarded party wrote is still on the chain, still signed by the Server, and
still verifies. Deactivation is a statement about credentials, not about history, and the chain is
append-only by construction: entries hash-link to their predecessors, so removing one would break
verification for every entry after it.

So after an offboarding:

- `GET /v1/records?performerAgentId=<agent>` still returns their work, and verification still
  passes.
- The [audit trail](/docs/operations/audit/) still names them as the actor on everything they did,
  including the deactivation itself.

What is gone is the ability to write anything new. If you need the party's data removed rather than
their access, that is a data-retention question and a different conversation: the engine holds what
principals rendered, and it is deliberately not able to selectively forget a record while keeping
the chain intact.

If the trigger for this runbook was a leaked credential rather than a planned departure, work the
[key compromise runbook](/docs/operations/key-compromise/) alongside it, and rotate the surrounding
secrets the [authentication guide](/docs/guides/authentication/) covers.
