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