Delegate, Present, Verify
The Ratify Protocol is three verbs. That’s it. Every adapter, every SDK, every integration — they’re all built out of some sequence of these three.
DELEGATE PRESENT VERIFY ──────── ─────── ────── Principal signs a Presenter (agent) Any third party DelegationCert carries the cert runs the verifier. naming the subject, and signs a fresh Both Ed25519 AND the scopes, and the challenge on every ML-DSA-65 must expiration. interaction. verify. Yes/no in <1ms. No trust Human → Agent OR Proves "this key is relationship with Agent → Agent. live right now." presenter required.The symmetry matters. A human delegating to an AI agent and one AI agent sub-delegating to another use the exact same primitive, the same verifier algorithm, and the same cryptographic guarantees.
1. Delegate
Section titled “1. Delegate”The principal — a human or another agent — signs a DelegationCert. The cert binds the
principal’s identity to the subject’s identity and specifies what the subject can do.
The bytes
Section titled “The bytes”A DelegationCert is the following JSON shape, serialized with canonical JSON
(keys sorted, no insignificant whitespace, deterministic number formatting):
{ "cert_id": "0a3b...c9d2", "version": 1, "issuer_id": "92cb0a15572d7a71", "issuer_pub_key": { "ed25519": "base64-encoded-32-bytes", "ml_dsa_65": "base64-encoded-1952-bytes" }, "subject_id": "b4a4c71795d676b6", "subject_pub_key": { "ed25519": "...", "ml_dsa_65": "..." }, "scope": ["meeting:attend", "meeting:speak"], "constraints": [], "issued_at": 1799996400, "expires_at": 1800082800, "signature": { "ed25519": "base64-encoded-64-bytes", "ml_dsa_65": "base64-encoded-3309-bytes" }}How the signature is computed
Section titled “How the signature is computed”delegationSignBytes = canonical_json(cert with signature field omitted)
signature.ed25519 = Ed25519.Sign(delegationSignBytes, issuer.private.ed25519)signature.ml_dsa_65 = ML-DSA-65.Sign(delegationSignBytes, issuer.private.ml_dsa_65)The canonical JSON is RFC 8785, with byte fields base64-standard-encoded. Both signatures are
computed over the same canonical bytes and both must verify at the receiving end. The
delegationSignBytes algorithm is defined precisely in
SPEC.md §7.1 and the
conformance fixtures pin it byte-identically across all four SDKs.
Why a hybrid signature
Section titled “Why a hybrid signature” Ed25519 alone Hybrid (Ed25519 + ML-DSA-65) ───────────── ────────────────────────────
Today ✓ Forgery-resistant ✓ Forgery-resistant
After Q-day ✗ BROKEN — quantum-capable ✓ Still resistant — ML-DSA-65 (large-scale adversary can forge any is lattice-based, not quantum Ed25519 signature ever number-theoretic. Quantum computer) produced, retroactively offers no known speedup. ("harvest now, decrypt later")
ML-DSA-65 alone is sufficient for Q-day defense. So why use Ed25519 too? Defense in depth. If a flaw is found in ML-DSA-65 (newer algorithm, less battle-tested), Ed25519 still holds the line.The hybrid posture is what FIPS 204, CNSA 2.0, and BSI guidance all recommend for the post-quantum transition.
2. Present
Section titled “2. Present”When the agent wants to do something, it builds a ProofBundle: the chain of delegations it
holds plus a fresh signature over a verifier-supplied challenge.
The bytes
Section titled “The bytes”{ "agent_id": "b4a4c71795d676b6", "agent_pub_key": { "ed25519": "...", "ml_dsa_65": "..." }, "delegations": [ /* [leaf, ..., root] — leaf-first per SPEC §6.5 */ ], "challenge": "Zx8t4vQrM2...", "challenge_at": 1800000000, "challenge_sig": { "ed25519": "...", "ml_dsa_65": "..." }, "session_context": "", // optional 32 bytes; SPEC §15.1 "stream_id": "", // optional 32 bytes; v1.1 "stream_seq": 0 // optional; ≥1 when stream_id is set}How the challenge signature is computed
Section titled “How the challenge signature is computed”The challenge signable is raw concatenated bytes, not canonical JSON:
sign_data = challenge // typically 32 random bytes || big-endian uint64(challenge_at) // 8 bytes || [optional] 32-byte session_context || [optional] 32-byte stream_id || big-endian int64(stream_seq)
challenge_sig.ed25519 = Ed25519.Sign(sign_data, agent.private.ed25519)challenge_sig.ml_dsa_65 = ML-DSA-65.Sign(sign_data, agent.private.ml_dsa_65)The agent’s identity isn’t in the signable — it’s established by the chain, and the verifier
runs hybrid verify against bundle.agent_pub_key. The full byte-layout, sizes, and the
three SDK entry points (SignChallenge, SignChallengeWithSessionContext,
SignChallengeWithStream) live on the Challenges page.
Why a fresh challenge
Section titled “Why a fresh challenge”Without freshness, an attacker who steals a bundle once could replay it forever. The challenge mechanism defeats this:
1. Verifier generates 32 random bytes (challenge).2. Verifier records the timestamp (challenge_at).3. Verifier sends both to the presenter.4. Presenter signs (challenge, challenge_at) with the agent's hybrid private key.5. Verifier rejects if challenge_at is older than ~5 minutes (configurable per surface — meetings might tolerate 10 minutes; high-assurance API calls, 30 seconds).The challenge is not stored long-term. The verifier doesn’t need to remember which challenges
it issued — it only needs to check that the embedded challenge_at timestamp is recent.
3. Verify
Section titled “3. Verify”Any third party with the principal’s public key can verify the entire bundle. Five deterministic checks. Sub-millisecond. Yes or no.
┌───────────────────────────────────-───────┐ bundle ─────────▶│ Verify(bundle, options) │ │ │ │ 1. Structural checks │ │ depth ∈ [1, MaxDelegationChainDepth] │ │ session_context/stream_id are 32B │ │ │ │ 2. Chain check │ │ For each link i in chain: │ │ cert[i].issuer_id == │ │ cert[i+1].subject_id │ │ Last cert's subject_id == │ │ bundle.agent_id │ │ │ │ 3. Per-cert checks │ │ For each delegation: │ │ - both hybrid sigs verify │ │ - issued_at ≤ now < expires_at │ │ - revocation provider says false │ │ - all constraints pass │ │ - intermediates carry │ │ identity:delegate │ │ │ │ 4. Liveness │ │ 0 ≤ now − challenge_at ≤ 300 s │ │ hybrid verify of challenge_sig over │ │ challenge || ts || session_ctx || │ │ stream_id || stream_seq │ │ │ │ 5. Scope │ │ effective = ⋂ link.scope (lex sort) │ │ required_scope ∈ effective │ │ │ │ 6. Policy (if provided) │ │ PolicyVerdict or PolicyProvider OK │ └────────────────┬───────────────────────-──┘ │ ▼ ┌──────────┴───────────┐ ▼ ▼ ┌───────────────┐ ┌──────────────────────────┐ │ Valid=true │ │ Valid=false, with one of:│ │ identity_ │ │ │ │ status: │ │ expired │ │ authorized_ │ │ revoked │ │ agent │ │ scope_denied │ │ granted_scope │ │ constraint_denied │ │ human_id │ │ constraint_unverifiable │ │ agent_id │ │ constraint_unknown │ │ │ │ delegation_not_ │ │ │ │ authorized │ │ │ │ invalid │ └───────────────┘ └──────────────────────────┘Fail-closed. Any check failing → Valid=false plus a specific identity_status →
caller rejects the request. There is no “valid with warnings” path, no “partially valid”
state. invalid is the catch-all for structural / cryptographic failures and always carries
a machine-parsable error_reason prefix (e.g. stale_challenge: ...,
bad_challenge_sig: ..., revocation_error: ...).
Multi-hop chains
Section titled “Multi-hop chains”Agent-to-agent sub-delegation uses the exact same primitive — the chain just gets longer.
Alice (human) │ │ signs DelegationCert { │ issuer: Alice │ subject: Agent-A │ scope: [meeting:*, identity:delegate] ← identity:delegate │ } is REQUIRED for A │ to sub-delegate. ▼ Agent-A │ │ signs DelegationCert { │ issuer: Agent-A │ subject: Agent-B │ scope: [meeting:attend, meeting:record] │ (identity:delegate intentionally NOT passed on) │ } ▼ Agent-B │ │ builds ProofBundle { │ agent_id: Agent-B │ delegations: [A→B, Alice→A] ← leaf-first per §6.5 │ challenge_sig: signed by Agent-B's private key │ } ▼ Verifier checks: ├ Both delegations' hybrid sigs valid (Ed25519 + ML-DSA-65) ├ A→B.issuer_id == Alice→A.subject_id (chain well-formed) ├ Alice→A scope contains identity:delegate │ (else identity_status: delegation_not_authorized) ├ All certs within issued_at/expires_at ├ effective_scope = scope[Alice→A] ∩ scope[A→B], lex-sorted │ = scope[meeting:*-expansion + identity:delegate] ∩ │ {meeting:attend, meeting:record} │ = {meeting:attend, meeting:record} └ required_scope ∈ effective_scope → authorized_agentThe verifier runs the same algorithm for chain depth 1 and chain depth MaxDelegationChainDepth
— same code path, just loops more times. Runtime grows linearly with chain depth.
Effective scope is the lex-sorted intersection across the chain. An agent cannot grant more rights than it was given. The structural invariant that makes sub-delegation safe.
Source
Section titled “Source”The verifier algorithm is normative — defined in SPEC.md §8. Every SDK implements the same algorithm; the 59 conformance fixtures verify byte-identical results.
Where to next
Section titled “Where to next”- Scopes — the canonical 53-scope vocabulary plus the
custom:extension pattern - Constraints — geo / time / version gating
- Challenges & freshness — replay protection deep dive
- Revocation — signed revocation lists
- Hybrid post-quantum crypto — why two signatures, not one