Skip to main content

agent_protocols/
delegation.rs

1//! Agent Delegation Protocol 1.0 types and validation helpers.
2//!
3//! Delegation events are ordinary Agent Identity envelopes whose actor is a
4//! controller key for an HTTPS principal document.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::error::{Result, SdkError};
11use crate::identity::{
12    validate_event_fields, validate_origin, verify_envelope, AcceptedRecord, AgentId, Envelope,
13    Event, ListResponse,
14};
15use url::Url;
16
17pub const PROTOCOL: &str = "agent-delegation/1.0";
18pub const DELEGATION_GRANT: &str = "delegation.grant";
19pub const DELEGATION_REVOKE: &str = "delegation.revoke";
20
21/// Delegation-specific error codes (Agent Delegation Section 9.7).
22pub const DELEGATION_ERROR_CODES: [&str; 8] = [
23    "principal_unresolvable",
24    "principal_not_canonical",
25    "controller_not_current",
26    "delegation_not_permitted",
27    "delegation_ceiling_exceeded",
28    "credential_not_found",
29    "credential_identity_mismatch",
30    "grant_expired",
31];
32
33#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub enum DelegationStatus {
36    Active,
37    Suspended,
38    Expired,
39    Revoked,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
43pub struct PrincipalLink {
44    pub name: String,
45    pub url: String,
46    pub rel: String,
47}
48
49/// Display descriptor of a principal, used by Agent Profile delegation hints.
50/// The descriptor is open: other members are display metadata, kept in
51/// `extra` so a signed hint re-serializes to the bytes that were signed.
52#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
53pub struct PrincipalDescriptor {
54    pub id: String,
55    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
56    pub kind: Option<String>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub name: Option<String>,
59    #[serde(default, flatten)]
60    pub extra: BTreeMap<String, Value>,
61}
62
63impl PrincipalDescriptor {
64    pub fn new(id: impl Into<String>) -> Self {
65        Self {
66            id: id.into(),
67            kind: None,
68            name: None,
69            extra: BTreeMap::new(),
70        }
71    }
72}
73
74/// Same type for active and retired bindings. Missing delegation means signing-only.
75#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
76pub struct Controller {
77    pub id: AgentId,
78    pub source: String,
79    pub valid_from: i64,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub name: Option<String>,
82    #[serde(
83        default,
84        deserialize_with = "non_null_option",
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub delegation: Option<DelegationAuthority>,
88    #[serde(
89        default,
90        deserialize_with = "non_null_option",
91        skip_serializing_if = "Option::is_none"
92    )]
93    pub retired_at: Option<i64>,
94    #[serde(
95        default,
96        deserialize_with = "non_null_option",
97        skip_serializing_if = "Option::is_none"
98    )]
99    pub invalid_from: Option<i64>,
100}
101
102impl Controller {
103    /// A current controller record with no delegation authority.
104    pub fn new(id: AgentId, source: impl Into<String>, valid_from: i64) -> Self {
105        Self {
106            id,
107            source: source.into(),
108            valid_from,
109            name: None,
110            delegation: None,
111            retired_at: None,
112            invalid_from: None,
113        }
114    }
115}
116
117#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
118#[serde(untagged)]
119pub enum DelegationAuthority {
120    /// Only the literal "*" is valid; validation rejects all other strings.
121    Unrestricted(String),
122    Restricted(DelegationPolicy),
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
126#[serde(deny_unknown_fields)]
127pub struct DelegationPolicy {
128    pub scopes: Vec<String>,
129    pub audiences: Vec<String>,
130}
131
132fn non_null_option<'de, D, T>(deserializer: D) -> std::result::Result<Option<T>, D::Error>
133where
134    D: serde::Deserializer<'de>,
135    T: Deserialize<'de>,
136{
137    T::deserialize(deserializer).map(Some)
138}
139
140#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
141pub struct PrincipalDocument {
142    pub id: String,
143    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
144    pub kind: Option<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub name: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub description: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub avatar_url: Option<String>,
151    /// Other HTTPS URLs that lead to this principal. Aliases are not identities.
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub aliases: Vec<String>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub links: Vec<PrincipalLink>,
156    pub protocol: String,
157    pub controllers: Vec<Controller>,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub retired_controllers: Vec<Controller>,
160    /// Origin of the authoritative delegation service; required when any
161    /// controller carries `delegation`.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub delegation_service: Option<String>,
164    pub updated_at: i64,
165    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166    pub extra: BTreeMap<String, Value>,
167}
168
169#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
170pub struct DelegationGrantPayload {
171    pub id: String,
172    pub principal_id: String,
173    pub subject: AgentId,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub relationship: Option<String>,
176    pub scopes: Vec<String>,
177    pub audiences: Vec<String>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub constraints: Option<BTreeMap<String, Value>>,
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub not_before: Option<i64>,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub expires_at: Option<i64>,
184}
185
186impl DelegationGrantPayload {
187    pub fn new(
188        id: impl Into<String>,
189        principal_id: impl Into<String>,
190        subject: AgentId,
191        scopes: Vec<String>,
192        audiences: Vec<String>,
193    ) -> Self {
194        Self {
195            id: id.into(),
196            principal_id: principal_id.into(),
197            subject,
198            relationship: None,
199            scopes,
200            audiences,
201            constraints: None,
202            not_before: None,
203            expires_at: None,
204        }
205    }
206}
207
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
209pub struct DelegationRevokePayload {
210    pub id: String,
211    pub principal_id: String,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub reason: Option<String>,
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
217#[serde(untagged)]
218pub enum DelegationPayload {
219    Grant(DelegationGrantPayload),
220    Revoke(DelegationRevokePayload),
221}
222
223#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
224pub struct DelegationCredential {
225    pub id: String,
226    pub protocol: String,
227    pub principal_id: String,
228    pub subject: AgentId,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub relationship: Option<String>,
231    pub scopes: Vec<String>,
232    pub audiences: Vec<String>,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub constraints: Option<BTreeMap<String, Value>>,
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub not_before: Option<i64>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub expires_at: Option<i64>,
239    pub status: DelegationStatus,
240    pub controller: AgentId,
241    pub grant_event_id: String,
242    pub event_id: String,
243    pub accepted_at: i64,
244    pub checked_at: i64,
245}
246
247pub type DelegationRecord = AcceptedRecord<DelegationPayload>;
248
249#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
250pub struct DelegationServiceDiscovery {
251    pub protocol: String,
252    pub service: String,
253    pub endpoints: DelegationServiceEndpoints,
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub features: Vec<String>,
256}
257
258#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
259pub struct DelegationServiceEndpoints {
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub delegations: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub query: Option<String>,
264}
265
266#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
267pub struct DelegationQueryRequest {
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub subject: Option<AgentId>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub principal_id: Option<String>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub id: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub status: Option<DelegationStatus>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub limit: Option<u32>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub cursor: Option<String>,
280}
281
282/// Agent Identity list of full credentials.
283pub type DelegationQueryResponse = ListResponse<DelegationCredential>;
284
285/// Agent Identity list of accepted records in service acceptance order.
286pub type DelegationEventsResponse = ListResponse<DelegationRecord>;
287
288/// Result of [`verify_delegation_credential`].
289#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
290pub struct DelegationVerdict {
291    pub credential: DelegationCredential,
292    /// Latest-grant signature, controller, ceiling, and consistency checks passed.
293    pub verified: bool,
294    /// Verified, and usable for the audience now.
295    pub usable: bool,
296    /// Every failed check.
297    pub reasons: Vec<String>,
298}
299
300pub fn delegation_grant_event(
301    actor: AgentId,
302    created_at: i64,
303    nonce: u64,
304    payload: DelegationGrantPayload,
305) -> Event<DelegationGrantPayload> {
306    Event::new(
307        PROTOCOL,
308        DELEGATION_GRANT,
309        actor,
310        created_at,
311        nonce,
312        payload,
313    )
314}
315
316pub fn delegation_revoke_event(
317    actor: AgentId,
318    created_at: i64,
319    nonce: u64,
320    payload: DelegationRevokePayload,
321) -> Event<DelegationRevokePayload> {
322    Event::new(
323        PROTOCOL,
324        DELEGATION_REVOKE,
325        actor,
326        created_at,
327        nonce,
328        payload,
329    )
330}
331
332pub fn validate_controller(controller: &Controller, retired: bool) -> Result<()> {
333    controller.id.public_key_bytes()?;
334    if controller.source != "local" {
335        validate_origin(&controller.source)?;
336    }
337    timestamp(controller.valid_from, "valid_from")?;
338    if let Some(name) = &controller.name {
339        validate_non_empty(name, "name")?;
340    }
341    match &controller.delegation {
342        Some(DelegationAuthority::Unrestricted(value)) if value != "*" => {
343            return fail("invalid delegation policy")
344        }
345        Some(DelegationAuthority::Restricted(policy)) => {
346            strings(&policy.scopes, "scopes", false)?;
347            strings(&policy.audiences, "audiences", false)?;
348            for audience in &policy.audiences {
349                validate_audience(audience)?;
350            }
351        }
352        _ => {}
353    }
354    if retired {
355        let end = controller
356            .retired_at
357            .ok_or_else(|| SdkError::InvalidPayload("retired_at required".into()))?;
358        timestamp(end, "retired_at")?;
359        if end < controller.valid_from {
360            return fail("retired_at precedes valid_from");
361        }
362        if let Some(cutoff) = controller.invalid_from {
363            timestamp(cutoff, "invalid_from")?;
364            if cutoff < controller.valid_from || cutoff > end {
365                return fail("invalid compromise interval");
366            }
367        }
368    } else if controller.retired_at.is_some() || controller.invalid_from.is_some() {
369        return fail("current controller has retirement fields");
370    }
371    Ok(())
372}
373
374pub fn validate_principal_document(document: &PrincipalDocument) -> Result<()> {
375    validate_https_url(&document.id, "principal.id")?;
376    if document.protocol != PROTOCOL {
377        return fail("invalid principal protocol");
378    }
379    timestamp(document.updated_at, "updated_at")?;
380    let mut records: BTreeMap<String, &Controller> = BTreeMap::new();
381    let mut delegates = false;
382    for (list, retired) in [
383        (&document.controllers, false),
384        (&document.retired_controllers, true),
385    ] {
386        for record in list {
387            validate_controller(record, retired)?;
388            if records.insert(record.id.to_string(), record).is_some() {
389                return fail("duplicate controller key");
390            }
391            delegates |= record.delegation.is_some();
392            if record.valid_from > document.updated_at
393                || record.retired_at.is_some_and(|v| v > document.updated_at)
394            {
395                return fail("controller timestamp exceeds document update");
396            }
397        }
398    }
399    strings(&document.aliases, "aliases", true)?;
400    for alias in &document.aliases {
401        validate_https_url(alias, "alias")?;
402    }
403    if let Some(url) = &document.avatar_url {
404        validate_https_url(url, "avatar_url")?;
405    }
406    match &document.delegation_service {
407        Some(origin) => validate_origin(origin)?,
408        None if delegates => {
409            return fail("delegation_service is required when a controller carries delegation")
410        }
411        None => {}
412    }
413    Ok(())
414}
415
416/// Checks the authoritative-read rule of Agent Delegation Section 3: a
417/// principal document binds controller keys only when it is read at its own
418/// `id`. A document served anywhere else is a copy; its `controllers` must be
419/// discarded and `document.id` resolved instead.
420pub fn validate_principal_resolution(
421    document: &PrincipalDocument,
422    resolved_url: &str,
423) -> Result<()> {
424    if document.id == resolved_url {
425        Ok(())
426    } else {
427        Err(SdkError::InvalidPayload(format!(
428            "principal document id {} was served at {resolved_url}",
429            document.id
430        )))
431    }
432}
433
434/// Reports whether `url` is an alias the principal itself acknowledges. Any
435/// origin can redirect to any principal, so an alias must not be shown as a
436/// name for the principal unless it is listed here.
437pub fn is_principal_alias(document: &PrincipalDocument, url: &str) -> bool {
438    document.aliases.iter().any(|alias| alias == url)
439}
440
441/// The Section 5 management rule: a restricted controller may replace, revoke,
442/// or enumerate a credential only when every scope and audience of its latest
443/// grant is within the controller's ceiling; an unrestricted controller covers
444/// every credential of the principal.
445pub fn controller_covers_credential(
446    controller: &Controller,
447    credential: &DelegationCredential,
448) -> bool {
449    within_ceiling(controller, &credential.scopes, &credential.audiences)
450}
451
452/// The Section 5 ceiling shared by issuance and management.
453fn within_ceiling(controller: &Controller, scopes: &[String], audiences: &[String]) -> bool {
454    match &controller.delegation {
455        None => false,
456        Some(DelegationAuthority::Restricted(policy)) => {
457            scopes.iter().all(|x| policy.scopes.contains(x))
458                && audiences.iter().all(|x| policy.audiences.contains(x))
459        }
460        Some(_) => unrestricted(controller),
461    }
462}
463
464pub fn validate_delegation_grant_payload(
465    payload: &DelegationGrantPayload,
466    created_at: Option<i64>,
467) -> Result<()> {
468    validate_delegation_id(&payload.id)?;
469    validate_https_url(&payload.principal_id, "principal_id")?;
470    payload.subject.public_key_bytes()?;
471    strings(&payload.scopes, "scopes", false)?;
472    strings(&payload.audiences, "audiences", false)?;
473    for audience in &payload.audiences {
474        validate_audience(audience)?;
475    }
476    if let Some(time) = created_at {
477        timestamp(time, "created_at")?;
478    }
479    if let Some(time) = payload.not_before {
480        timestamp(time, "not_before")?;
481    }
482    if let Some(time) = payload.expires_at {
483        timestamp(time, "expires_at")?;
484    }
485    if let Some(expires_at) = payload.expires_at {
486        if matches!(payload.not_before, Some(not_before) if expires_at <= not_before) {
487            return Err(SdkError::protocol(
488                "grant_expired",
489                "expires_at must be greater than not_before",
490            ));
491        }
492        if matches!(created_at, Some(created_at) if expires_at <= created_at) {
493            return Err(SdkError::protocol(
494                "grant_expired",
495                "expires_at must be greater than created_at",
496            ));
497        }
498    }
499    Ok(())
500}
501
502/// A public delegation query is an existence check and must include both
503/// `subject` and `principal_id`. Omitting either side makes it an enumeration
504/// query, which services must authorize before answering; pass
505/// `allow_enumeration` when building such an authorized request. `limit`
506/// defaults to 20; services SHOULD cap it at 100.
507pub fn validate_delegation_query_request(
508    request: &DelegationQueryRequest,
509    allow_enumeration: bool,
510) -> Result<()> {
511    if allow_enumeration {
512        if request.subject.is_none() && request.principal_id.is_none() {
513            return Err(SdkError::protocol(
514                "invalid_request",
515                "query must include at least one of subject or principal_id",
516            ));
517        }
518    } else if request.subject.is_none() || request.principal_id.is_none() {
519        return Err(SdkError::protocol(
520            "invalid_request",
521            "public query must include both subject and principal_id",
522        ));
523    }
524    if let Some(subject) = &request.subject {
525        subject.public_key_bytes()?;
526    }
527    if let Some(principal_id) = &request.principal_id {
528        validate_https_url(principal_id, "principal_id")?;
529    }
530    if let Some(id) = &request.id {
531        validate_delegation_id(id)?;
532    }
533    if matches!(request.limit, Some(0)) {
534        return Err(SdkError::protocol(
535            "invalid_request",
536            "limit must be a positive integer",
537        ));
538    }
539    Ok(())
540}
541
542pub fn validate_delegation_revoke_payload(payload: &DelegationRevokePayload) -> Result<()> {
543    validate_delegation_id(&payload.id)?;
544    validate_https_url(&payload.principal_id, "principal_id")
545}
546
547/// Delegation IDs are unreserved URL characters (`[A-Za-z0-9._~-]{1,128}`)
548/// and never a dot segment: no percent-encoding, no look-alikes.
549pub fn validate_delegation_id(value: &str) -> Result<()> {
550    let valid = (1..=128).contains(&value.len())
551        && value
552            .bytes()
553            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'~' | b'-'))
554        && !matches!(value, "." | "..");
555    if valid {
556        Ok(())
557    } else {
558        fail("delegation id must match [A-Za-z0-9._~-]{1,128} and not be a dot segment")
559    }
560}
561
562pub fn validate_delegation_envelope(envelope: &Envelope<DelegationPayload>) -> Result<()> {
563    verify_envelope(envelope)?;
564    // Delegation events carry only the six Agent Identity event fields.
565    validate_event_fields(&envelope.event, &[])?;
566    if envelope.event.protocol != PROTOCOL {
567        return Err(SdkError::InvalidEventProtocol {
568            expected: PROTOCOL.to_owned(),
569            actual: envelope.event.protocol.clone(),
570        });
571    }
572    match (&envelope.event.kind[..], &envelope.event.payload) {
573        (DELEGATION_GRANT, DelegationPayload::Grant(payload)) => {
574            validate_delegation_grant_payload(payload, Some(envelope.event.created_at))
575        }
576        (DELEGATION_REVOKE, DelegationPayload::Revoke(payload)) => {
577            validate_delegation_revoke_payload(payload)
578        }
579        (DELEGATION_GRANT, _) | (DELEGATION_REVOKE, _) => Err(SdkError::InvalidPayload(
580            "delegation event type does not match payload shape".to_owned(),
581        )),
582        _ => Err(SdkError::InvalidEventType {
583            expected: format!("{DELEGATION_GRANT} or {DELEGATION_REVOKE}"),
584            actual: envelope.event.kind.clone(),
585        }),
586    }
587}
588
589/// Materializes an already accepted event with trusted previous state and the
590/// service's actual acceptance time. This helper does not authorize the event.
591pub fn materialize_delegation_credential(
592    envelope: &Envelope<DelegationPayload>,
593    status: DelegationStatus,
594    accepted_at: i64,
595    previous: Option<&DelegationCredential>,
596) -> Result<DelegationCredential> {
597    validate_delegation_envelope(envelope)?;
598    timestamp(accepted_at, "accepted_at")?;
599    check_previous(&envelope.event, previous)?;
600    if previous.is_some_and(|p| accepted_at < p.accepted_at) {
601        return fail("acceptance order reversed");
602    }
603    match &envelope.event.payload {
604        DelegationPayload::Revoke(_) => {
605            let mut result = previous
606                .ok_or_else(|| {
607                    SdkError::protocol(
608                        "credential_not_found",
609                        "revocation requires previous credential",
610                    )
611                })?
612                .clone();
613            result.controller = envelope.event.actor.clone();
614            result.status = DelegationStatus::Revoked;
615            result.event_id = envelope.hash.clone();
616            result.accepted_at = accepted_at;
617            result.checked_at = accepted_at;
618            Ok(result)
619        }
620        DelegationPayload::Grant(payload) => {
621            if payload.expires_at.is_some_and(|t| t <= accepted_at) {
622                return Err(SdkError::protocol(
623                    "grant_expired",
624                    "grant expired at acceptance",
625                ));
626            }
627            Ok(DelegationCredential {
628                id: payload.id.clone(),
629                protocol: PROTOCOL.into(),
630                principal_id: payload.principal_id.clone(),
631                subject: payload.subject.clone(),
632                relationship: payload.relationship.clone(),
633                scopes: payload.scopes.clone(),
634                audiences: payload.audiences.clone(),
635                constraints: payload.constraints.clone(),
636                not_before: payload.not_before,
637                expires_at: payload.expires_at,
638                status,
639                controller: envelope.event.actor.clone(),
640                grant_event_id: envelope.hash.clone(),
641                event_id: envelope.hash.clone(),
642                accepted_at,
643                checked_at: accepted_at,
644            })
645        }
646    }
647}
648
649/// Pre-signing policy check over trusted document/state. No signature, HTTP,
650/// cache-age, live timestamp-window, or nonce verification is performed.
651pub fn validate_delegation_event_authority(
652    event: &Event<DelegationPayload>,
653    document: &PrincipalDocument,
654    accepted_at: i64,
655    previous: Option<&DelegationCredential>,
656) -> Result<()> {
657    check_authority(event, document, accepted_at, previous, false)
658}
659
660/// Caller also enforces fresh HTTPS, live Identity replay rules, and atomic persistence.
661pub fn validate_delegation_acceptance(
662    envelope: &Envelope<DelegationPayload>,
663    document: &PrincipalDocument,
664    resolved_url: &str,
665    accepted_at: i64,
666    previous: Option<&DelegationCredential>,
667) -> Result<()> {
668    validate_delegation_envelope(envelope)?;
669    validate_principal_resolution(document, resolved_url)?;
670    check_authority(&envelope.event, document, accepted_at, previous, false)
671}
672
673/// Caller authenticates the accepted record and previous state. This checks
674/// historical policy, not current status or application constraints.
675pub fn validate_historical_delegation(
676    record: &DelegationRecord,
677    document: &PrincipalDocument,
678    resolved_url: &str,
679    previous: Option<&DelegationCredential>,
680) -> Result<()> {
681    validate_delegation_envelope(&record.envelope)?;
682    validate_principal_resolution(document, resolved_url)?;
683    check_authority(
684        &record.envelope.event,
685        document,
686        record.accepted_at,
687        previous,
688        true,
689    )
690}
691
692/// Per-result check; caller authenticates the actor and resolves the document.
693pub fn validate_controller_enumeration(
694    document: &PrincipalDocument,
695    actor: &AgentId,
696    now: i64,
697    credential: &DelegationCredential,
698) -> Result<()> {
699    validate_principal_document(document)?;
700    timestamp(now, "now")?;
701    strings(&credential.scopes, "scopes", false)?;
702    strings(&credential.audiences, "audiences", false)?;
703    let c = document
704        .controllers
705        .iter()
706        .find(|c| &c.id == actor)
707        .filter(|c| now >= c.valid_from)
708        .ok_or_else(|| {
709            SdkError::protocol("controller_not_current", "controller cannot enumerate")
710        })?;
711    if c.delegation.is_none() {
712        return Err(SdkError::protocol(
713            "delegation_not_permitted",
714            "controller cannot enumerate",
715        ));
716    }
717    if !controller_covers_credential(c, credential) {
718        return Err(SdkError::protocol(
719            "delegation_ceiling_exceeded",
720            "credential is outside the controller's ceiling",
721        ));
722    }
723    Ok(())
724}
725
726/// Audience/status/time only, after signature/history checks. Caller still
727/// authenticates the subject and enforces scopes and constraints.
728pub fn validate_delegation_use(
729    credential: &DelegationCredential,
730    audience: &str,
731    now: i64,
732) -> Result<()> {
733    validate_audience(audience)?;
734    timestamp(now, "now")?;
735    validate_delegation_grant_payload(&grant_from_credential(credential), None)?;
736    if credential.protocol != PROTOCOL
737        || credential.status != DelegationStatus::Active
738        || !credential.audiences.iter().any(|a| a == audience)
739        || credential.not_before.is_some_and(|t| now < t)
740        || credential.expires_at.is_some_and(|t| now >= t)
741    {
742        return fail("delegation is not usable");
743    }
744    Ok(())
745}
746
747/// Verifies a credential under Agent Delegation Section 8 with the online
748/// service-trusting evidence policy: checks the accepted record of its latest
749/// grant — signature, Controller binding, ceiling, and authority interval —
750/// confirms that the grant matches the credential, and then checks use for
751/// `audience` at `now`. The Section 5 management rule governs management,
752/// which the service enforced at acceptance; auditors replay it with
753/// [`audit_delegation_history`]. Relying parties still enforce scopes and
754/// constraints and authenticate the subject.
755pub fn verify_delegation_credential(
756    credential: &DelegationCredential,
757    records: &[DelegationRecord],
758    document: &PrincipalDocument,
759    resolved_url: &str,
760    audience: &str,
761    now: i64,
762) -> DelegationVerdict {
763    let mut reasons = Vec::new();
764    if let Err(error) = latest_grant_matches(credential, records, document, resolved_url) {
765        reasons.push(error.to_string());
766    }
767    let verified = reasons.is_empty();
768    if credential.status != DelegationStatus::Active {
769        let status = serde_json::to_value(credential.status)
770            .ok()
771            .and_then(|value| value.as_str().map(str::to_owned))
772            .unwrap_or_default();
773        reasons.push(format!("status is {status}"));
774    }
775    if !credential.audiences.iter().any(|a| a == audience) {
776        reasons.push(format!("audience {audience} is not granted"));
777    }
778    if credential.not_before.is_some_and(|t| now < t) {
779        reasons.push("not yet valid".to_owned());
780    }
781    if credential.expires_at.is_some_and(|t| now >= t) {
782        reasons.push("expired".to_owned());
783    }
784    DelegationVerdict {
785        credential: credential.clone(),
786        verified,
787        usable: reasons.is_empty(),
788        reasons,
789    }
790}
791
792/// Section 8 relying-party check: the latest grant record is authorized by a
793/// Controller of the principal and agrees with the credential.
794fn latest_grant_matches(
795    credential: &DelegationCredential,
796    records: &[DelegationRecord],
797    document: &PrincipalDocument,
798    resolved_url: &str,
799) -> Result<()> {
800    let record = records
801        .iter()
802        .find(|record| record.envelope.hash == credential.grant_event_id)
803        .ok_or_else(|| SdkError::InvalidPayload("latest grant record is missing".into()))?;
804    validate_historical_delegation(record, document, resolved_url, None)?;
805    let DelegationPayload::Grant(grant) = &record.envelope.event.payload else {
806        return fail("grant_event_id does not name a grant");
807    };
808    if credential.protocol != PROTOCOL
809        || serde_jcs::to_vec(grant)? != serde_jcs::to_vec(&grant_from_credential(credential))?
810    {
811        return fail("credential does not match its latest grant");
812    }
813    if credential.event_id == credential.grant_event_id {
814        if credential.accepted_at != record.accepted_at
815            || credential.controller != record.envelope.event.actor
816            || credential.status == DelegationStatus::Revoked
817        {
818            return fail("credential does not match its latest grant");
819        }
820    } else if credential.status != DelegationStatus::Revoked {
821        return fail("a credential last changed by a revocation must be revoked");
822    }
823    Ok(())
824}
825
826/// Auditor check (Section 8): replays every accepted record of a credential
827/// against the authoritative principal document — signatures, controller
828/// intervals, and ceilings — and confirms that the replay
829/// matches the credential. Relying parties use
830/// [`verify_delegation_credential`] instead.
831pub fn audit_delegation_history(
832    credential: &DelegationCredential,
833    records: &[DelegationRecord],
834    document: &PrincipalDocument,
835    resolved_url: &str,
836) -> Result<()> {
837    if records.is_empty() {
838        return fail("no accepted records");
839    }
840    let mut replayed: Option<DelegationCredential> = None;
841    for record in records {
842        validate_historical_delegation(record, document, resolved_url, replayed.as_ref())?;
843        replayed = Some(materialize_delegation_credential(
844            &record.envelope,
845            DelegationStatus::Active,
846            record.accepted_at,
847            replayed.as_ref(),
848        )?);
849    }
850    let r = replayed.expect("at least one record");
851    // Only status and its service timestamps may differ from event replay.
852    if serde_jcs::to_vec(&grant_from_credential(&r))?
853        != serde_jcs::to_vec(&grant_from_credential(credential))?
854        || r.protocol != credential.protocol
855        || r.accepted_at != credential.accepted_at
856        || r.event_id != credential.event_id
857        || r.grant_event_id != credential.grant_event_id
858        || r.controller != credential.controller
859        || (r.status == DelegationStatus::Revoked)
860            != (credential.status == DelegationStatus::Revoked)
861    {
862        return fail("credential does not match its accepted records");
863    }
864    Ok(())
865}
866
867fn grant_from_credential(credential: &DelegationCredential) -> DelegationGrantPayload {
868    DelegationGrantPayload {
869        id: credential.id.clone(),
870        principal_id: credential.principal_id.clone(),
871        subject: credential.subject.clone(),
872        relationship: credential.relationship.clone(),
873        scopes: credential.scopes.clone(),
874        audiences: credential.audiences.clone(),
875        constraints: credential.constraints.clone(),
876        not_before: credential.not_before,
877        expires_at: credential.expires_at,
878    }
879}
880
881fn event_identity(event: &Event<DelegationPayload>) -> (&str, &str) {
882    match &event.payload {
883        DelegationPayload::Grant(p) => (&p.id, &p.principal_id),
884        DelegationPayload::Revoke(p) => (&p.id, &p.principal_id),
885    }
886}
887fn check_previous(
888    event: &Event<DelegationPayload>,
889    previous: Option<&DelegationCredential>,
890) -> Result<()> {
891    let (id, principal) = event_identity(event);
892    if let Some(previous) = previous {
893        strings(&previous.scopes, "previous.scopes", false)?;
894        strings(&previous.audiences, "previous.audiences", false)?;
895        timestamp(previous.accepted_at, "previous.accepted_at")?;
896        let same_subject = match &event.payload {
897            DelegationPayload::Grant(p) => p.subject == previous.subject,
898            DelegationPayload::Revoke(_) => true,
899        };
900        if previous.id != id
901            || previous.principal_id != principal
902            || previous.protocol != PROTOCOL
903            || !same_subject
904        {
905            return Err(SdkError::protocol(
906                "credential_identity_mismatch",
907                "credential principal, subject, and protocol are immutable",
908            ));
909        }
910    } else if event.kind == DELEGATION_REVOKE {
911        return Err(SdkError::protocol(
912            "credential_not_found",
913            "revocation requires previous credential",
914        ));
915    }
916    Ok(())
917}
918fn unrestricted(c: &Controller) -> bool {
919    matches!(&c.delegation, Some(DelegationAuthority::Unrestricted(v)) if v == "*")
920}
921fn check_authority(
922    event: &Event<DelegationPayload>,
923    document: &PrincipalDocument,
924    accepted_at: i64,
925    previous: Option<&DelegationCredential>,
926    historical: bool,
927) -> Result<()> {
928    validate_principal_document(document)?;
929    timestamp(accepted_at, "accepted_at")?;
930    timestamp(event.created_at, "created_at")?;
931    event.actor.public_key_bytes()?;
932    if event.protocol != PROTOCOL {
933        return fail("invalid event protocol");
934    }
935    match (&event.kind[..], &event.payload) {
936        (DELEGATION_GRANT, DelegationPayload::Grant(p)) => {
937            validate_delegation_grant_payload(p, Some(event.created_at))?
938        }
939        (DELEGATION_REVOKE, DelegationPayload::Revoke(p)) => validate_delegation_revoke_payload(p)?,
940        _ => return fail("event type does not match payload"),
941    }
942    if event_identity(event).1 != document.id {
943        return Err(SdkError::protocol(
944            "principal_not_canonical",
945            "principal mismatch",
946        ));
947    }
948    let c = document
949        .controllers
950        .iter()
951        .chain(document.retired_controllers.iter().filter(|_| historical))
952        .find(|c| c.id == event.actor)
953        .ok_or_else(|| {
954            SdkError::protocol(
955                "controller_not_current",
956                "actor is not a controller of the principal",
957            )
958        })?;
959    if c.delegation.is_none() {
960        return Err(SdkError::protocol(
961            "delegation_not_permitted",
962            "actor has no delegation authority",
963        ));
964    }
965    for time in [event.created_at, accepted_at] {
966        if time < c.valid_from
967            || c.retired_at.is_some_and(|t| time >= t)
968            || c.invalid_from.is_some_and(|t| time >= t)
969        {
970            return Err(SdkError::protocol(
971                "controller_not_current",
972                "outside controller authority interval",
973            ));
974        }
975    }
976    check_previous(event, previous)?;
977    if let Some(previous) = previous {
978        if accepted_at < previous.accepted_at {
979            return fail("acceptance order reversed");
980        }
981        if !controller_covers_credential(c, previous) {
982            return Err(SdkError::protocol(
983                "delegation_ceiling_exceeded",
984                "credential is outside the controller's ceiling",
985            ));
986        }
987    }
988    if let DelegationPayload::Grant(payload) = &event.payload {
989        if payload.expires_at.is_some_and(|t| t <= accepted_at) {
990            return Err(SdkError::protocol(
991                "grant_expired",
992                "grant expired at acceptance",
993            ));
994        }
995        if !within_ceiling(c, &payload.scopes, &payload.audiences) {
996            return Err(SdkError::protocol(
997                "delegation_ceiling_exceeded",
998                "grant exceeds controller delegation policy",
999            ));
1000        }
1001    }
1002    Ok(())
1003}
1004fn fail<T>(message: &str) -> Result<T> {
1005    Err(SdkError::InvalidPayload(message.into()))
1006}
1007fn timestamp(value: i64, field: &str) -> Result<()> {
1008    if !(0..=9_007_199_254_740_991).contains(&value) {
1009        return fail(&format!("{field} must be a non-negative safe integer"));
1010    }
1011    Ok(())
1012}
1013fn strings(values: &[String], field: &str, empty: bool) -> Result<()> {
1014    if !empty && values.is_empty() {
1015        return fail(&format!("{field} must be a non-empty array"));
1016    }
1017    let mut seen = BTreeSet::new();
1018    for value in values {
1019        validate_non_empty(value, field)?;
1020        if value == "*" || !seen.insert(value) {
1021            return fail(&format!("{field} has wildcard or duplicates"));
1022        }
1023    }
1024    Ok(())
1025}
1026/// A relying-party audience (Section 5): an origin for a relying
1027/// application, or an Agent ID for a relying agent.
1028pub fn validate_audience(value: &str) -> Result<()> {
1029    if value.starts_with(crate::identity::AGENT_ID_PREFIX) {
1030        value.parse::<AgentId>().map(|_| ())
1031    } else {
1032        validate_origin(value)
1033    }
1034}
1035
1036fn validate_https_url(value: &str, field: &str) -> Result<()> {
1037    let parsed = Url::parse(value)
1038        .map_err(|_| SdkError::InvalidPayload(format!("{field} must be an HTTPS URL")))?;
1039    if parsed.scheme() == "https" && parsed.host_str().is_some() {
1040        Ok(())
1041    } else {
1042        Err(SdkError::InvalidPayload(format!(
1043            "{field} must be an HTTPS URL"
1044        )))
1045    }
1046}
1047
1048fn validate_non_empty(value: &str, field: &str) -> Result<()> {
1049    if value.trim().is_empty() {
1050        Err(SdkError::InvalidPayload(format!(
1051            "{field} must not be empty"
1052        )))
1053    } else {
1054        Ok(())
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061    use crate::identity::AgentSigner;
1062
1063    #[test]
1064    fn validates_and_materializes_grant() {
1065        let controller = AgentSigner::from_seed([31; 32]);
1066        let subject = AgentSigner::from_seed([32; 32]);
1067        let mut payload = DelegationGrantPayload::new(
1068            "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q",
1069            "https://api.al.ink/d9c6a99cne5g00a6scn0",
1070            subject.agent_id(),
1071            vec!["inbox.screen".to_owned(), "meeting.propose".to_owned()],
1072            vec!["https://dmsg.net".to_owned()],
1073        );
1074        payload.relationship = Some("primary_delegate".to_owned());
1075        payload.not_before = Some(1_779_753_600_000);
1076        payload.expires_at = Some(1_790_000_000_000);
1077        let envelope = controller
1078            .sign_event(delegation_grant_event(
1079                controller.agent_id(),
1080                1_779_753_600_000,
1081                1,
1082                payload,
1083            ))
1084            .unwrap();
1085        let envelope_value = serde_json::to_value(&envelope).unwrap();
1086        let envelope_for_validation: Envelope<DelegationPayload> =
1087            serde_json::from_value(envelope_value).unwrap();
1088
1089        validate_delegation_envelope(&envelope_for_validation).unwrap();
1090        let credential = materialize_delegation_credential(
1091            &envelope_for_validation,
1092            DelegationStatus::Active,
1093            1_779_753_600_000,
1094            None,
1095        )
1096        .unwrap();
1097
1098        assert_eq!(credential.protocol, PROTOCOL);
1099        assert_eq!(credential.controller, controller.agent_id());
1100        assert_eq!(credential.subject, subject.agent_id());
1101        assert_eq!(
1102            credential.principal_id,
1103            "https://api.al.ink/d9c6a99cne5g00a6scn0"
1104        );
1105        assert_eq!(credential.event_id, envelope.hash);
1106    }
1107
1108    #[test]
1109    fn validates_revoke_and_rejects_invalid_grants() {
1110        let controller = AgentSigner::from_seed([33; 32]);
1111        let envelope = controller
1112            .sign_event(delegation_revoke_event(
1113                controller.agent_id(),
1114                1_779_753_700_000,
1115                2,
1116                DelegationRevokePayload {
1117                    id: "del_01J8ZM7A3G2T9B4Q6X8R0N1P2Q".to_owned(),
1118                    principal_id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
1119                    reason: Some("rotated_primary_agent".to_owned()),
1120                },
1121            ))
1122            .unwrap();
1123        let envelope: Envelope<DelegationPayload> =
1124            serde_json::from_value(serde_json::to_value(envelope).unwrap()).unwrap();
1125        validate_delegation_envelope(&envelope).unwrap();
1126
1127        let invalid = DelegationGrantPayload::new(
1128            "del",
1129            "http://example.com",
1130            controller.agent_id(),
1131            Vec::new(),
1132            vec!["https://dmsg.net".to_owned()],
1133        );
1134        assert!(validate_delegation_grant_payload(&invalid, None).is_err());
1135    }
1136
1137    #[test]
1138    fn grant_expiry_checked_against_not_before_and_created_at() {
1139        let controller = AgentSigner::from_seed([36; 32]);
1140        let base = DelegationGrantPayload::new(
1141            "del_1",
1142            "https://api.al.ink/d9c6a99cne5g00a6scn0",
1143            controller.agent_id(),
1144            vec!["inbox.screen".to_owned()],
1145            vec!["https://dmsg.net".to_owned()],
1146        );
1147
1148        let mut same_as_not_before = base.clone();
1149        same_as_not_before.not_before = Some(2000);
1150        same_as_not_before.expires_at = Some(2000);
1151        assert!(validate_delegation_grant_payload(&same_as_not_before, Some(1000)).is_err());
1152
1153        let mut before_created_at = base.clone();
1154        before_created_at.not_before = Some(500);
1155        before_created_at.expires_at = Some(800);
1156        assert!(validate_delegation_grant_payload(&before_created_at, Some(1000)).is_err());
1157
1158        let mut valid = base;
1159        valid.not_before = Some(500);
1160        valid.expires_at = Some(1500);
1161        validate_delegation_grant_payload(&valid, Some(1000)).unwrap();
1162    }
1163
1164    #[test]
1165    fn public_delegation_queries_carry_both_subject_and_principal() {
1166        let subject = AgentSigner::from_seed([37; 32]).agent_id();
1167        let principal_id = "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned();
1168        let public = DelegationQueryRequest {
1169            subject: Some(subject.clone()),
1170            principal_id: Some(principal_id.clone()),
1171            limit: Some(20),
1172            ..DelegationQueryRequest::default()
1173        };
1174        validate_delegation_query_request(&public, false).unwrap();
1175
1176        // Enumerating one side is not a public query.
1177        for one_sided in [
1178            DelegationQueryRequest {
1179                subject: Some(subject.clone()),
1180                ..DelegationQueryRequest::default()
1181            },
1182            DelegationQueryRequest {
1183                principal_id: Some(principal_id),
1184                ..DelegationQueryRequest::default()
1185            },
1186        ] {
1187            assert!(validate_delegation_query_request(&one_sided, false).is_err());
1188            validate_delegation_query_request(&one_sided, true).unwrap();
1189        }
1190
1191        assert!(
1192            validate_delegation_query_request(&DelegationQueryRequest::default(), true).is_err()
1193        );
1194        assert!(validate_delegation_query_request(
1195            &DelegationQueryRequest {
1196                limit: Some(0),
1197                ..public
1198            },
1199            false
1200        )
1201        .is_err());
1202    }
1203
1204    #[test]
1205    fn principal_documents_bind_controllers_only_at_their_own_id() {
1206        let controller = AgentSigner::from_seed([38; 32]);
1207        let document = PrincipalDocument {
1208            id: "https://api.al.ink/d9c6a99cne5g00a6scn0".to_owned(),
1209            kind: None,
1210            name: None,
1211            description: None,
1212            avatar_url: None,
1213            aliases: vec!["https://al.ink/yan".to_owned()],
1214            links: Vec::new(),
1215            protocol: PROTOCOL.into(),
1216            retired_controllers: vec![],
1217            controllers: vec![Controller::new(controller.agent_id(), "local", 0)],
1218            delegation_service: None,
1219            updated_at: 1000,
1220            extra: BTreeMap::new(),
1221        };
1222
1223        validate_principal_resolution(&document, "https://api.al.ink/d9c6a99cne5g00a6scn0")
1224            .unwrap();
1225        // A copy served away from its identifier carries no authority.
1226        assert!(
1227            validate_principal_resolution(&document, "https://impostor.example.com/yan").is_err()
1228        );
1229
1230        assert!(is_principal_alias(&document, "https://al.ink/yan"));
1231        assert!(!is_principal_alias(
1232            &document,
1233            "https://impostor.example.com/yan"
1234        ));
1235    }
1236
1237    #[test]
1238    fn validates_principal_document() {
1239        let controller = AgentSigner::from_seed([34; 32]);
1240        let document = PrincipalDocument {
1241            id: "https://profiles.example.com/org/acme".to_owned(),
1242            kind: None,
1243            name: None,
1244            description: None,
1245            avatar_url: None,
1246            aliases: vec!["https://profiles.example.com/acme".to_owned()],
1247            links: Vec::new(),
1248            protocol: PROTOCOL.into(),
1249            retired_controllers: vec![],
1250            controllers: vec![Controller::new(controller.agent_id(), "local", 0)],
1251            delegation_service: Some("https://profiles.example.com".to_owned()),
1252            updated_at: 1000,
1253            extra: BTreeMap::new(),
1254        };
1255        validate_principal_document(&document).unwrap();
1256
1257        let mut invalid = document.clone();
1258        invalid.protocol = "old-draft".into();
1259        assert!(validate_principal_document(&invalid).is_err());
1260
1261        // A controller with delegation authority needs an authoritative query endpoint.
1262        let mut delegating = document;
1263        delegating.controllers[0].delegation = Some(DelegationAuthority::Unrestricted("*".into()));
1264        validate_principal_document(&delegating).unwrap();
1265        delegating.delegation_service = None;
1266        assert!(validate_principal_document(&delegating).is_err());
1267    }
1268
1269    #[test]
1270    fn rejects_malformed_https_like_urls() {
1271        let signer = AgentSigner::from_seed([35; 32]);
1272        for principal_id in ["https://", "https://[::1", "http://example.com"] {
1273            let payload = DelegationGrantPayload::new(
1274                "del",
1275                principal_id,
1276                signer.agent_id(),
1277                vec!["scope".to_owned()],
1278                vec!["https://dmsg.net".to_owned()],
1279            );
1280            assert!(validate_delegation_grant_payload(&payload, None).is_err());
1281        }
1282    }
1283}