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:
GET /v1/records?performerAgentId=<agent>still returns their work, and verification still passes.- The audit trail 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 alongside it, and rotate the surrounding secrets the authentication guide covers.