Skip to content

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.

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.

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"
}
}
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.

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.

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.

{
"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": "..." }
}
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)

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.

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.”

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.

The verifier algorithm is normative — defined in SPEC.md §8. Every SDK implements the same algorithm; the 59 conformance fixtures verify byte-identical results.