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; 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.

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.

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 gets there through provisioning YAML (ownerType: agent), or was carried over from an earlier version: no agent scope profile carries webhooks:manage, so no agent registers one through the API. Which of the two it is 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.

# 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.

# 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, and decide about each.
curl -s "$API/v1/admin/api-keys?orgId=<org>&isActive=true" -H "Authorization: Bearer $KEY" \
  | jq '.data[] | select(.createdByKeyId=="<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. 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 and createdBefore are the filters it does take. Every one of them is ANDed with your own org, so an admin key cannot reach beyond it:

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, and section 3 is the lever.

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, 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:

# 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.

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:

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 alongside it, and rotate the surrounding secrets the authentication guide covers.