Revocation
A DelegationCert carries issued_at and expires_at. The natural way for it to stop being
valid is to wait until now > expires_at. But sometimes you need it to stop being valid
right now — the agent’s key was leaked, the principal changed their mind, the relationship
ended. That’s what revocation is for.
┌─────────────────────────────────────────────┐ │ Principal (Alice) │ │ │ │ Decides to revoke a cert she issued │ │ (e.g. agent's key was leaked). │ └──────────────────┬──────────────────────────┘ │ │ 1. Signs a RevocationList: │ issuer_id: <Alice's ID> │ updated_at: now │ revoked_certs: ["cert-abc", ...] │ signature: hybrid (Ed25519+ML-DSA-65) ▼ ┌─────────────────────────────────────────────┐ │ Distribution │ │ │ │ - Self-hosted by Alice, OR │ │ - Hosted by a registry, OR │ │ - Pushed in real time via RevocationPush │ └──────────────────┬──────────────────────────┘ │ │ consulted by the verifier's │ RevocationProvider on every Verify ▼ ┌─────────────────────────────────────────────┐ │ Verifier │ │ │ │ For each cert in bundle.delegations: │ │ revoked, err := provider.IsRevoked(id) │ │ if err → identity_status: invalid │ │ (revocation_error) │ │ if revoked → identity_status: revoked │ └─────────────────────────────────────────────┘The RevocationList wire shape
Section titled “The RevocationList wire shape”RevocationList is the canonical, hybrid-signed payload an issuer publishes (types.go →
RevocationList):
{ "issuer_id": "92cb0a15572d7a71", "updated_at": 1800000000, "revoked_certs": [ "cert-abc-001", "cert-xyz-042" ], "signature": { "ed25519": "...", "ml_dsa_65": "..." }}That’s it — four fields. The list is signed by the same root key that signed the certs
it’s revoking, so consumers can authenticate it without a CA. There’s no version,
expires_at, or per-entry metadata on the wire; freshness is managed at the
distribution layer via TTL on the fetch, not by an embedded expiry.
There is also a v1.1 push variant (RevocationPush) — a signed delta carrying
{issuer_id, seq_no, entries, pushed_at, signature} — for verifiers that maintain a long-
lived subscription and need sub-second propagation. Edge / serverless verifiers stick with the
pull model.
The RevocationProvider hook (SPEC §17.1)
Section titled “The RevocationProvider hook (SPEC §17.1)”The verifier never fetches a RevocationList itself. Revocation is a provider hook you
plug into VerifyOptions — that way the same SDK can talk to a self-hosted endpoint, a
managed registry, a Redis cache, or all three in front of each other.
The interface is one method:
// Gotype RevocationProvider interface { IsRevoked(certID string) (bool, error)}// TypeScriptinterface RevocationProvider { isRevoked(certID: string): Promise<boolean>; // throw on lookup failure}# Pythonclass RevocationProvider(Protocol): def is_revoked(self, cert_id: str) -> bool: ... # raise on lookup failure// Rustpub trait RevocationProvider { fn is_revoked(&self, cert_id: &str) -> Result<bool, String>;}Wire it up by setting VerifyOptions.Revocation (Go field name; equivalents in TS/Py/Rust):
opts := ratify.VerifyOptions{ RequiredScope: "meeting:attend", Revocation: myProvider,}result := ratify.Verify(&bundle, opts)Fail-closed by design
Section titled “Fail-closed by design”The verifier is fail-closed: a provider that returns an error fails the cert as
identity_status: invalid with error_reason: "revocation_error: ...". There is no
fallback to “allow because we can’t tell” — a verifier that doesn’t know revocation state
MUST NOT report a cert as valid.
The three outcomes:
| Provider returns | identity_status | error_reason |
|---|---|---|
(false, nil) | continues to next check | — |
(true, nil) | revoked | cert-xyz revoked |
(_, err) | invalid | revocation_error: <err> |
Provider implementations decide their own freshness policy: a RevocationList cached in
Redis with a 60-second TTL behaves differently from one served directly by Cloudflare KV
with edge-replicated push. The RevocationProvider interface is intentionally narrow so
that policy is yours, not the SDK’s.
Legacy IsRevoked closure
Section titled “Legacy IsRevoked closure”For v1.0 backward compatibility, VerifyOptions still accepts a plain closure
IsRevoked: func(certID string) bool. It is deprecated because there’s no way to
surface lookup failures — the closure must collapse “I don’t know” into either false
(allow) or true (deny). It will be removed in v1.0.0-beta.1. Use RevocationProvider
in new code.
When both are set, RevocationProvider wins.
ForceRevocationCheck
Section titled “ForceRevocationCheck”VerifyOptions.ForceRevocationCheck = true is the high-stakes path: it signals your provider
implementation that it should bypass any local cache and re-fetch the freshest state. The
verifier itself doesn’t perform the fetch — that’s your provider’s job — but it does
guarantee one safety check: if ForceRevocationCheck is true and neither
RevocationProvider nor IsRevoked is set, the bundle is failed with
force_revocation_no_callback. The caller asked for fresh revocation state but provided no
way to check it.
Sub-delegation: every cert in the chain is checked
Section titled “Sub-delegation: every cert in the chain is checked”When the chain is Alice → Agent-A → Agent-B, every cert in bundle.delegations is passed
through IsRevoked independently:
For each cert in [A→B, Alice→A]: revoked, err := provider.IsRevoked(cert.cert_id) ...Alice can revoke Alice→A — which transitively kills everything Agent-A sub-delegated, because the chain check fails on the first link. Agent-A can revoke A→B without affecting Alice’s relationship with Agent-A.
The protocol does not support a “revoke everything from Alice” call. Revocation is always
per-cert. If you need a global revoke, your provider returns (true, nil) for every
cert_id issued by Alice’s root.
Recommended freshness budgets
Section titled “Recommended freshness budgets”Suggested TTLs by surface (the protocol doesn’t mandate these — they’re operational guidance):
| Surface | Cache TTL | Max staleness before fail-closed |
|---|---|---|
| Meeting verification | 60 s | 300 s |
| Voice gateway | 30 s | 120 s |
| API gateway | 60 s | 300 s |
| Physical AI / offline | 5 min | mission-duration cached |
Tighter TTL = revocations propagate faster, more network calls. Looser TTL = better availability during partial outages, slower propagation. Pick a max-staleness your provider enforces by returning an error past that threshold — the verifier will then fail closed.
Revocation vs. short expiry
Section titled “Revocation vs. short expiry”| Revocation | Short expires_at | |
|---|---|---|
| Mechanism | RevocationProvider.IsRevoked per Verify | Plain timestamp check, no I/O |
| Propagation speed | Seconds (TTL-bounded) | Bounded by cert lifetime |
| Verifier cost | Network call (cacheable) | None |
| Operational burden | Maintain a list endpoint or registry | None |
| Best for | Compromised keys, terminated relationships | Routine refresh, blast-radius shrinkage |
Common pattern: short expiry + revocation together. Issue certs with 1-hour expiry by default, refresh on a heartbeat, only revoke when something goes wrong. Worst case is a 1-hour blast radius even if revocation hosting is completely down.
Source
Section titled “Source”RevocationList and RevocationPush wire shapes: types.go. RevocationProvider interface
and the verifier integration: verify.go. SPEC.md §11 (revocation) and §17.1 (provider hook)
are normative.
Where to next
Section titled “Where to next”- Provider architecture — all §17 hooks (revocation, policy, audit, …)
- Key custody — what to do when a private key is actually leaked
- Verify overview — managed revocation hosting if you don’t want to run it yourself