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