Runbooks¶
The procedures an on-call operator needs. Each one opens with its trigger, then the steps, and ends with the check that proves it worked.
Conventions: agentryd is the server binary; $DB is the database URL;
psql "$DB" assumes the app role. Anything destructive says so in bold.
"The status page" is where a deployment tells its users about an outage, on a
host other than its own; each deployment names its own.
1. Deploy and roll back¶
Trigger: a release is ready, or a release is misbehaving.
Deploy. Migrations run before the new code, and every migration in this project is additive-or-compatible so old and new code can run side by side during a rollout:
- Review the infrastructure change first (
helm diff,docker compose config, or the deployment's own plan step); a change that touches the database, the repo storage or the signing key stops here and gets a second pair of eyes. - Apply migrations:
agentryd migrate(idempotent; takes a session lock, so a second concurrent run waits instead of racing). - Roll the services one at a time:
workerfirst (it is the least user-visible), thenapi,git. A site in front rolls last, from its own repository. - Watch: 5xx rate, publish→verdict p95, queue age. Give it ten minutes.
Roll back. Code rolls back freely; migrations do not. Redeploy the
previous image tag and leave the schema alone. If a migration itself is the
problem, write a new forward migration. Never goose down in production.
Verify: /readyz green on every task, publish→verdict p50 back under
10 minutes, no new S0/pipeline-error findings.
2. Migration failure mid-deploy¶
Trigger: agentryd migrate exits non-zero, or tasks crash-loop on boot.
- Read the goose error. It names the migration and the failing statement.
- Check whether it partially applied:
psql "$DB" -c "select version_id, is_applied, tstamp from goose_db_version order by id desc limit 5". - If the failed migration did not apply, fix it forward and redeploy. If it
partially applied (possible only for migrations with multiple statements
outside a transaction), reconcile by hand in a transaction, then mark it:
psql "$DB" -c "insert into goose_db_version (version_id, is_applied) values (N, true)". - Old code keeps serving throughout. Do not scale to zero to "get a clean slate".
Verify: agentryd migrate is a no-op, tasks boot, /readyz green.
3. Queue stuck or poison job¶
Trigger: page on queue age > 15 minutes, or versions sitting in scanning.
The queue is River (queue.driver: river, the only driver) on the
river_job table.
- Depth and age by queue:
- Are workers alive?
agentryd workerlogs aworker startedline and then claims jobs; no claims for minutes withavailablerows means the workers are gone, not slow. Restart them. - Orphaned claims (a worker died holding jobs): River's rescuer returns a
job whose worker stopped reporting to
availableon its own after the rescue interval; a job stuck inrunningpast that is one to look at, not to edit. - Poison job (same job failing repeatedly):
Capture the last error, open a bug. A discarded scan job leaves its version in
select id, kind, attempt, errors from river_job where state='discarded' order by finalized_at desc limit 20;scanning. That is fail-closed and correct; the version is not published. Thescan.reconcilejob then moves any version older than 30 minutes inscanningto review with anS0/pipeline-errorfinding, so a person decides; after the fix, re-submit the version rather than transitioning it by hand.
Never move a version to published in SQL to clear a backlog. The gate is
the product.
Verify: queue age falling, available count draining, no version older
than 30 minutes in scanning.
4. Quarantine wave (mass malicious publish)¶
Trigger: an abuse report cluster, an S8 publisher-anomaly spike, or a rescan sweep surfacing many new high findings at once. The ClawHavoc pattern is hundreds of malicious agents from a handful of accounts, so assume the blast radius is an account cluster, not one agent.
A rescan sweep is the most likely way you find out. Registering a new ruleset release enqueues the catalogue sweep automatically; you do not schedule it. Shipping detection for a live technique surfaces every already-published version that trips it, all at once. Expect a wave of quarantines after a rule that matters lands, and read it as the system working rather than as an incident of its own.
S8's publisher-behaviour signal covers account age × publish velocity
and the IP cluster (S8/ip-cluster: three
or more accounts and ten or more versions publishing from one keyed IP hash
within 24 hours). The actual ClawHavoc incident (1,184 skills from 12
accounts) turned on that axis; the finding narrows step 1 below, it does not
replace it. Neither signal is raised for a platform-staff submitter,
so a burst from a staff account carries no S8 publisher finding:
if one is in the cluster, scope it by the query below, not by the findings.
- Scope it before acting. Find the cluster:
Look for shared signals: account age, IP, naming pattern, identical findings.
-- recent publishes by account, newest first select u.id, h.handle, count(*) as versions, min(av.created_at) as first_seen from agent_versions av join users u on u.id = av.submitted_by join handles h on h.id = u.id where av.created_at > now() - interval '48 hours' group by 1,2 order by versions desc limit 50; - Stop the inflow first. Suspend the accounts (
update users set state='suspended' where id in (…)). This kills sessions and PATs on next use. New publishes stop; existing content is untouched. - Quarantine, then block. Quarantine is reversible: it stops resolution
and refuses direct downloads with
409. Block is for confirmed malware and returns410 Goneon every download of that agent or version. Work through the API (POST /platform/agents/{ns}/{name}/quarantine, then.../block) so every action is audited with a reason, not in SQL. - Publish one advisory per affected agent (or one covering the cluster, referenced from each). Consumers who already installed are reached by advisory + webhook, not by any push we control.
- Write the timeline as you go. Who, what, when, and what signal caught it. This becomes the postmortem and, usually, the next S8 rule.
Dual control: blocked should be set by a second platform admin, not the
one who quarantined. If you are alone, block anyway and get review after.
Verify: no new versions from the cluster; quarantined/blocked states
reflected on the agent pages; an actual curl of a blocked version's
download URL returns 410 (verify the effect, not the state column); advisories visible; audit
trail complete.
5. Signing-key rotation¶
Trigger: scheduled rotation, suspected exposure, or a KMS Sign anomaly
alert.
Clients pin what they have seen (TOFU), so a rotation is visible to every
installed client: at its next login it is told the advertised key ids
changed. What a rotation does not do is orphan what the old key
signed. The registry records the public part of every key it signs with
(signing_keys); the outgoing key stays in
/.well-known/agentry/signing-keys.json with status: retired for as long
as an attestation carries its signature; and a start on a key that some
attestation lacks queues scan.reattest_sweep, which appends the new key's
countersignature to each of those attestations (the statement is checked
against its release first and never changed). A client that pins today
verifies everything; a client that pinned the old key keeps verifying the
original signatures.
- Create the new key (your KMS or a new file key). Keep the old key material: an attestation it signed stays verifiable through its own signature for clients that pinned it, and never schedule deletion of a key that signed a published version.
- Point signing at the new key (
AGENTRY_SIGNING_KMS_KEY_ARN, or the file key) and roll the role that holds it: the worker (all, on compose). The api and git roles hold no key and need no restart: they read the active key and the ruleset attestation from what the worker recorded at start, within the key set's five-minute cache. At start the log readsattestations lack the signing key; re-attestation sweep enqueuedwith the count; the worker then logsre-attestation sweep completewith how many it signed. A start on a key every attestation already carries logs nothing. - Check: the key set lists the new key as
activeand the old one asretiredwith its date (curl -s …/.well-known/agentry/signing-keys.json);SELECT count(*) FROM attestations WHERE NOT (dsse_envelope->'signatures' @> '[{"keyid":"<new key id>"}]')is zero. A count that stays above zero after the sweep names rows the sweep would not sign — the log linere-attest: not signedsays why for each (a statement that does not describe its release is a corrupted row, not something to vouch for). The next start queues the sweep again for whatever is still pending. signing.additionalKeysis still honoured for a key the registry has no record of (one that signed before the record existed, or a verification key from elsewhere); a configured key the registry also has on record carries the record's status. Nothing needs removing later: a retired key drops out of the set by itself once no attestation carries its signature.
If the key is suspected compromised: rotate immediately as above, then
audit kms:Sign calls against the pipeline's own signing and the sweep, and
diff the attestations table against agent_versions where
status='published'. Any attestation without a matching published version
is a forgery signal; so is a signature by the compromised key dated after
the rotation. Publish a security advisory; do not quietly rotate. A
compromised key stays advertised as retired — its old signatures are still
what clients that pinned it verify against — and the advisory is what tells
them to re-pin.
Verify: agentry verify succeeds against a pre-rotation version from a
freshly logged-in client (pins the new set), and against both a pre- and a
post-rotation version from a client that pinned the old key and re-pinned
after the warning.
6. Database restore (point in time)¶
Trigger: data loss or corruption. RPO 15 minutes, RTO 4 hours.
- Stop writes: scale
api,git, andworkerto zero. Leave the edge (and a site in front) up: it will show errors, which is better than silent divergence. - Restore to a new instance at the chosen timestamp — from your Postgres
backups, or on AWS
aws rds restore-db-instance-to-point-in-time. Never restore over the live one. - Point the app at the restored endpoint, run
agentryd migrate(no-op if the restore is post-migration), scaleapi/workerback up. - Reconcile object storage: S3 release archives are content-addressed and write-once, so they are ahead of, never behind, the database. Any archive whose version row vanished in the restore is unreferenced garbage. Leave it. Conversely, a version row referencing a missing archive means the restore went back past a publish: those versions are broken and must be quarantined, not silently served:
- Announce the restore window on the status page; publishes inside it were lost and authors must re-publish (version strings are burned, so they need a new version number; say so explicitly).
Verify: /readyz green, a test publish reaches published, agentry
verify passes on a pre-incident version.
7. Repo storage restore¶
Trigger: git repos missing or corrupt.
- Repos are keyed by immutable UUID (
repos.id), so a restore never has to guess ownership. - Restore the storage snapshot (the volume's own snapshot; on AWS the
EFS recovery point from AWS Backup) to a new
file system, mount it read-only alongside, and copy only the affected
{uuid[0:2]}/{uuid}.gitdirectories back. - A missing repo is recoverable from any clone:
git push --mirror. Release archives are not affected by EFS loss; they live in S3 and are what consumers download. Published versions keep working through a total repo loss; only pushes and new git-tag publishes break.
Verify: git clone of an affected agent works; agentry install of its
published version was never affected.
8. Abuse and DDoS response¶
Trigger: WAF block spike, anonymous rate-limit saturation, or a report flood.
- Identify the shape: per-IP flood (WAF rate rules), distributed scraping of
/search(cache and raise the anonymous limit), or targeted publish abuse (see runbook 4, Quarantine wave). - Cheap levers first: WAF rate rule threshold,
AGENTRY_LIMITS_*anonymous limits, CloudFront caching on public GETs. Release downloads are content-addressed and CDN-cached, so they should never be the bottleneck; if they are, check thatCache-Control: immutableis surviving. - Do not disable authentication rate limits to "reduce load"; login is the endpoint you least want unthrottled.
Verify: 5xx back to baseline, publish pipeline unaffected, no legitimate
publisher locked out (check the 401s in the access lines, event=http.request
by client_ip, and the sign-in provider's own failed-sign-in events).
9. GDPR erasure request¶
Trigger: a verified account-owner request.
- The self-service path is
POST /api/v1/user/delete. Prefer it; it is audited and transactional. - It purges PII, pseudonymizes the account, retires the handle (reserved, not recycled), revokes every credential, and disposes of published public versions without ever deleting them: consumers who already installed keep a verifiable artifact, which is the contract consumers rely on. Explain this to the requester; it is a lawful-interest position, not an oversight.
- The requester must choose a disposition. If the account has any
published public versions, the call requires
publishedContent=yankorpublishedContent=transfer(the latter also needstransferOrg=<org handle>, and the caller must be an owner or admin there); without it the request is refused with400naming the count. There is no default, so ask the requester before you run it, and record the answer. - If they insist published bytes be removed, that is a
blockeddecision by trust & safety with a written rationale, not an erasure step. - Audit events keep the opaque user id. Do not delete audit rows; the table rejects it at the database level anyway.
- Where an extension is the deployment's directory, the directory half (the
sign-in account) follows the registry erasure, and the response header
X-Agentry-Directory-Erasuresays how it went (pendingwhile the directory retries). Afailopen.directory_erasureline (or aFailOpenEvents{path="directory_erasure"}increment) means it did not complete: delete the sign-in account with the account's former address by hand (the requester supplies it; the registry does not hold it), and never an administrator's or a service account. Without a directory, the sign-in account at your provider is yours to delete.
Verify: the account cannot log in, the handle shows as retired, published
versions read yanked (or are owned by the destination org, if the requester
chose transfer), and a user.delete audit event exists recording the
disposition.
10. Security disclosure intake¶
Trigger: a report to security@.
- Acknowledge within 2 business days (SECURITY.md promises it).
- Reproduce on a scratch instance, never production. Never paste reporter proof-of-concept content into a production publish.
- Severity by what it bypasses: publication gate, authorization, or signing are automatically high or critical.
- Fix forward with a regression test. For scanner evasion, that means a new
fixture pair in
testdata/attack-corpus/so it can never silently return. - Ship, then publish the advisory with credit.
Verify: the regression test fails on the old code and passes on the new.
11. Sign-in provider unavailable or misconfigured¶
Trigger: sign-in fails or loops, /ui/login?error=… (or the site's
/login?error=…), or sign-out lands on a second login screen. Existing
registry sessions and PATs keep working throughout — the provider is
consulted at sign-in, not per request — so the API is not down; people
cannot sign in.
- Is the provider up? Its discovery document,
<issuer>/.well-known/openid-configuration, must answer from the registry's host. - Is the registry's client still what the provider has? The redirect URI is
{baseURL}/api/v1/auth/oidc/<id>/callback, the client secret isAGENTRY_AUTH_OIDC_CLIENT_SECRET, and the grant is the authorization code with PKCE. - Is the id unchanged?
auth.oidc.idprefixes every account the provider linked: a changed id signs everyone in as somebody new. - Does sign-out return?
AGENTRY_IDP_LOGOUT_URLmust be the provider's end-session endpoint.
Verify: GET https://<host>/api/v1/auth/oidc/<id>/start?next=%2Fui%2F
in a browser ends, after the provider's sign-in, on /ui/, which says
"You are signed in as ui.mode is authorize, /ui/ is
not served: open https://<host>/api/v1/user in the same browser instead,
which answers with the signed-in account.
12. Dependency alert wave (a popular MCP server gets a CVE)¶
Trigger: McpDependencyAlertsOpened jumps in one sweep, authors write in
about the "Package issue" banner, or a mcp sweep: done … alertsOpened=N
log line with a large N.
This is the system working: the daily sweep (scan.mcp_sweep) re-checked
every scanned MCP server version, one of them gained a high or critical
advisory, and every published QuickAgent version declaring it got a
dependency alert — banner and dashboard for the author, one email, an
agent.dependency_alert audit event, and the org webhook. Nothing was
quarantined: the versions stay installable, and the alert closes on its
own when the author yanks the version or the advisory is withdrawn.
- Confirm the advisory is real.
GET /api/v1/mcp-servers/scan?ecosystem=&package=&version=shows what the registry recorded (ids, severity, fixed versions); the ids resolve at osv.dev. A withdrawn or re-scored advisory clears itself at the next sweep; do not editmcp_server_scansby hand. - Scope it. How many published versions, how many authors:
- Decide whether it is also a trust-and-safety matter. A server with a
remote-code-execution advisory that a QuickAgent runs against its declared
secrets may deserve quarantine of the affected versions (runbook 4) and an
advisory; an alert alone is right for the ordinary case. The runner
refuses to run a QuickAgent whose declared server has an advisory the
registry's gate rates high or critical (its
gateSeverity, so a High below CVSS 8.5 that the gate rates medium runs; a registry that serves no rating is weighed by the advisory's own severity) — the check isagentry run's, before the plan executes, and the receipt's vulnerability line says what it found — unless the person passes--allow-vulnerable-mcpor the operator policy setsmcpServers.allowVulnerable, either of which the receipt names (agentry-cli:vulnerabilityVerdictincmd/agentry/cmd_run_mcp.go,VulnStatus.Refusesinpkg/runner/mcpinstall/vulncheck.go;agentry doctorwarns on the same status). The install step itself is not refused, and copies already installed keep the server until their next run meets the check. - Watch the sweep itself. A
mcp sweep: vulnerability lookup failed for a batchline (and a non-zerolookupErrorson themcp sweep: doneline) means the vulnerability database was unreachable: rows keep their last answer and are retried tomorrow; check egress from the worker toAGENTRY_SCAN_OSV_URL.McpSweepRuns{outcome="error"}counts a sweep that stopped on the registry's own database (listing, recording or opening alerts); the job is retried like any other. - Tell people once. Authors already got one mail per alert; a status note is for consumers, who see the alert on the agent page but get no mail.
Verify: the alert count stops growing after one sweep, affected authors'
new versions pin a fixed server (their S7 result is clean), and old versions
are yanked (their alerts show resolved_reason = version-yanked).