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": "base64url-encoded-32-bytes", "ml_dsa_65": "base64url-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": "base64url-encoded-64-bytes", "ml_dsa_65": "base64url-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)Both signatures are computed over the same canonical bytes. Both must verify at the receiving
end. The delegationSignBytes algorithm is defined precisely in
SPEC.md §7.1 and is what
the conformance fixtures verify byte-identically across 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": [ /* one or more DelegationCert objects */ ], "challenge": "Zx8t4vQrM2...", "challenge_at": 1800000000, "challenge_sig": { "ed25519": "...", "ml_dsa_65": "..." }}How the challenge signature is computed
Section titled “How the challenge signature is computed”challengeSignBytes = canonical_json({ agent_id: bundle.agent_id, challenge: bundle.challenge, challenge_at: bundle.challenge_at,})
challenge_sig.ed25519 = Ed25519.Sign(challengeSignBytes, agent.private.ed25519)challenge_sig.ml_dsa_65 = ML-DSA-65.Sign(challengeSignBytes, agent.private.ml_dsa_65)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(bundle, options) │ │ │ │ 1. Signature check │ │ For each delegation in chain: │ │ Ed25519.Verify(sigBytes, sig.ed) │ │ ML-DSA-65.Verify(sigBytes, sig.ml)│ │ Plus challenge_sig over the bundle. │ │ │ │ 2. Freshness check │ │ now - challenge_at < freshness_max │ │ │ │ 3. Expiry check │ │ For each delegation: │ │ cert.issued_at ≤ now │ │ cert.expires_at > now │ │ │ │ 4. Chain check │ │ For each link i in chain: │ │ cert[i].subject == cert[i+1].issuer │ First link's issuer is the trust root│ │ Last link's subject is the presenter│ │ │ │ 5. Scope check │ │ effective_scope = ⋂ link.scope │ │ required_scope ∈ effective_scope │ │ │ │ 6. Revocation check (if list provided) │ │ For each cert: cert_id ∉ revoked │ │ │ │ 7. Constraint check │ │ For each constraint: matches ctx? │ └────────────────┬─────────────────────────┘ │ ▼ ┌──────────┴───────────┐ ▼ ▼ ┌───────────────┐ ┌──────────────────┐ │ Valid │ │ Rejected with │ │ granted │ │ identity_status:│ │ scope: ... │ │ │ │ chain depth │ │ bad_signature │ │ identity: │ │ expired │ │ ... │ │ scope_denied │ └───────────────┘ │ revoked │ │ replay │ │ constraint_ │ │ violation │ └──────────────────┘Fail-closed. Any check failing → status is the specific failure reason → caller should reject the request. There is no “valid with warnings” path. There is no “partially valid.”
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:*] │ } ▼ Agent-A │ │ signs DelegationCert { │ issuer: Agent-A │ subject: Agent-B │ scope: [meeting:attend, meeting:record] │ } ▼ Agent-B │ │ builds ProofBundle { │ agent_id: Agent-B │ delegations: [Alice→A, A→B] ← chain depth 2 │ challenge_sig: signed by Agent-B's private key │ } ▼ Verifier checks: ├ Both delegations' signatures valid ├ Each delegation's subject == next delegation's issuer ├ All within expiry window ├ Effective scope = scope[Alice→A] ∩ scope[A→B] │ = {attend,speak,video,record,chat,share_screen} ∩ │ {attend,record} │ = {attend, record} └ If required_scope is in effective scope: ✓The verifier does the same thing for chain depth 1 and chain depth 5 — same code path, just loops more times. The verifier’s runtime grows linearly with chain depth.
Effective scope is the intersection across the chain. An agent cannot grant more rights than it itself was given. This is 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 52-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