Skip to main content

agent_protocols/
profile.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::delegation::{validate_delegation_id, PrincipalDescriptor};
6use crate::error::{Result, SdkError};
7use crate::identity::{
8    validate_event_fields, verify_envelope, AgentId, Envelope, Event, ListResponse,
9};
10
11pub const PROTOCOL: &str = "agent-profile/1.0";
12pub const PROFILE_UPDATE: &str = "profile.update";
13
14// Optional collections are `Option`s throughout this module: a signed
15// payload may carry an explicit `[]` or `{}`, and re-serializing it must
16// reproduce the signed bytes, so an empty value stays distinct from an
17// absent one.
18
19#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(deny_unknown_fields)]
21pub struct ServiceEndpoint {
22    #[serde(rename = "type")]
23    pub kind: String,
24    pub url: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub protocols: Option<Vec<String>>,
27}
28
29/// Defined link relationships. `rel` is an open vocabulary: clients accept
30/// other non-empty values and may render them as generic links.
31pub mod link_rel {
32    pub const HOMEPAGE: &str = "homepage";
33    pub const DOCUMENTATION: &str = "documentation";
34    pub const SOURCE_CODE: &str = "source_code";
35    pub const SOCIAL: &str = "social";
36    pub const BROWSER: &str = "browser";
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(deny_unknown_fields)]
41pub struct ProfileLink {
42    pub name: String,
43    pub url: String,
44    pub rel: String,
45}
46
47/// Discovery hint only. It carries no service URLs: the publishing agent is the
48/// party whose claim is checked, so clients resolve the principal document at
49/// `principal.id` and query the service it names.
50#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
51#[serde(deny_unknown_fields)]
52pub struct ProfileDelegationHint {
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub id: Option<String>,
55    pub principal: PrincipalDescriptor,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub relationship: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub scopes: Option<Vec<String>>,
60}
61
62/// The closed `profile.update` payload (Section 4.1): undefined fields are
63/// rejected at deserialization, and application data belongs in `extra`.
64#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
65#[serde(deny_unknown_fields)]
66pub struct ProfileUpdatePayload {
67    pub id: AgentId,
68    pub name: String,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub description: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub avatar_url: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub provider: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub capabilities: Option<Vec<String>>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub service_endpoints: Option<Vec<ServiceEndpoint>>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub links: Option<Vec<ProfileLink>>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub delegations: Option<Vec<ProfileDelegationHint>>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub extra: Option<BTreeMap<String, Value>>,
85}
86
87impl ProfileUpdatePayload {
88    pub fn new(id: AgentId, name: impl Into<String>) -> Self {
89        Self {
90            id,
91            name: name.into(),
92            description: None,
93            avatar_url: None,
94            provider: None,
95            capabilities: None,
96            service_endpoints: None,
97            links: None,
98            delegations: None,
99            extra: None,
100        }
101    }
102}
103
104/// The materialized profile document: the latest payload's fields exactly as
105/// signed, plus the service-derived `updated_at` and `event_id`.
106#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
107pub struct AgentProfile {
108    pub id: AgentId,
109    pub name: String,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub description: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub avatar_url: Option<String>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub provider: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub capabilities: Option<Vec<String>>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub service_endpoints: Option<Vec<ServiceEndpoint>>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub links: Option<Vec<ProfileLink>>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub delegations: Option<Vec<ProfileDelegationHint>>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub extra: Option<BTreeMap<String, Value>>,
126    pub updated_at: i64,
127    pub event_id: String,
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
131pub struct ProfileBatchReadRequest {
132    pub ids: Vec<AgentId>,
133}
134
135/// Agent Identity list of profile documents; never carries `next_cursor`.
136pub type ProfileBatchReadResponse = ListResponse<AgentProfile>;
137
138/// Agent Identity list of accepted updates, newest first by `nonce`.
139pub type ProfileEventsResponse = ListResponse<Envelope<ProfileUpdatePayload>>;
140
141#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
142pub struct ProfileServiceDiscovery {
143    pub protocol: String,
144    pub service: String,
145    pub endpoints: ProfileServiceEndpoints,
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub features: Vec<String>,
148}
149
150#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
151pub struct ProfileServiceEndpoints {
152    pub profiles: String,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub profile_batch: Option<String>,
155}
156
157pub fn profile_update_event(
158    actor: AgentId,
159    created_at: i64,
160    nonce: u64,
161    payload: ProfileUpdatePayload,
162) -> Event<ProfileUpdatePayload> {
163    Event::new(PROTOCOL, PROFILE_UPDATE, actor, created_at, nonce, payload)
164}
165
166pub fn validate_profile_update(envelope: &Envelope<ProfileUpdatePayload>) -> Result<()> {
167    verify_envelope(envelope)?;
168    // Profile events carry only the six Agent Identity event fields.
169    validate_event_fields(&envelope.event, &[])?;
170    if envelope.event.protocol != PROTOCOL {
171        return Err(SdkError::InvalidEventProtocol {
172            expected: PROTOCOL.to_owned(),
173            actual: envelope.event.protocol.clone(),
174        });
175    }
176    if envelope.event.kind != PROFILE_UPDATE {
177        return Err(SdkError::InvalidEventType {
178            expected: PROFILE_UPDATE.to_owned(),
179            actual: envelope.event.kind.clone(),
180        });
181    }
182    if envelope.event.actor != envelope.event.payload.id {
183        return Err(SdkError::InvalidActor(
184            "profile update actor must match payload.id".to_owned(),
185        ));
186    }
187    validate_profile_payload(&envelope.event.payload)
188}
189
190/// Section 4.1 field rules for a `profile.update` payload. The field set
191/// itself is closed by [`ProfileUpdatePayload`] deserialization.
192pub fn validate_profile_payload(payload: &ProfileUpdatePayload) -> Result<()> {
193    payload.id.public_key_bytes()?;
194    if payload.name.is_empty() {
195        return Err(invalid_payload("name must not be empty"));
196    }
197    if let Some(url) = &payload.avatar_url {
198        require_url(url, &["https"], "avatar_url")?;
199    }
200    unique_strings(payload.capabilities.as_deref(), "capabilities")?;
201    let mut endpoints = BTreeSet::new();
202    for endpoint in payload.service_endpoints.iter().flatten() {
203        if endpoint.kind.is_empty() {
204            return Err(invalid_payload("service endpoint type must not be empty"));
205        }
206        require_url(&endpoint.url, &["https"], "service endpoint url")?;
207        unique_strings(endpoint.protocols.as_deref(), "service endpoint protocols")?;
208        if !endpoints.insert((endpoint.kind.as_str(), endpoint.url.as_str())) {
209            return Err(invalid_payload(
210                "service endpoints must be unique by type and url",
211            ));
212        }
213    }
214    let mut links = BTreeSet::new();
215    for link in payload.links.iter().flatten() {
216        if link.name.is_empty() || link.rel.is_empty() {
217            return Err(invalid_payload("link name and rel must not be empty"));
218        }
219        require_url(&link.url, &["http", "https"], "link url")?;
220        if !links.insert((link.url.as_str(), link.rel.as_str())) {
221            return Err(invalid_payload("links must be unique by url and rel"));
222        }
223    }
224    for hint in payload.delegations.iter().flatten() {
225        if let Some(id) = &hint.id {
226            validate_delegation_id(id)?;
227        }
228        require_url(&hint.principal.id, &["https"], "delegation principal id")?;
229        unique_strings(hint.scopes.as_deref(), "delegation hint scopes")?;
230    }
231    Ok(())
232}
233
234fn invalid_payload(message: &str) -> SdkError {
235    SdkError::protocol("invalid_event", message)
236}
237
238fn require_url(value: &str, schemes: &[&str], field: &str) -> Result<()> {
239    match url::Url::parse(value) {
240        Ok(url) if schemes.contains(&url.scheme()) && url.host_str().is_some() => Ok(()),
241        _ => Err(SdkError::protocol(
242            "invalid_event",
243            format!("{field} must be an {} URL", schemes.join(" or ")),
244        )),
245    }
246}
247
248fn unique_strings(values: Option<&[String]>, field: &str) -> Result<()> {
249    let values = values.unwrap_or_default();
250    if values.iter().any(String::is_empty) {
251        return Err(SdkError::protocol(
252            "invalid_event",
253            format!("{field} entries must not be empty"),
254        ));
255    }
256    if values.iter().collect::<BTreeSet<_>>().len() != values.len() {
257        return Err(SdkError::protocol(
258            "invalid_event",
259            format!("{field} entries must be unique"),
260        ));
261    }
262    Ok(())
263}
264
265pub fn materialize_profile(envelope: &Envelope<ProfileUpdatePayload>) -> Result<AgentProfile> {
266    validate_profile_update(envelope)?;
267    let payload = &envelope.event.payload;
268    Ok(AgentProfile {
269        id: payload.id.clone(),
270        name: payload.name.clone(),
271        description: payload.description.clone(),
272        avatar_url: payload.avatar_url.clone(),
273        provider: payload.provider.clone(),
274        capabilities: payload.capabilities.clone(),
275        service_endpoints: payload.service_endpoints.clone(),
276        links: payload.links.clone(),
277        delegations: payload.delegations.clone(),
278        extra: payload.extra.clone(),
279        updated_at: envelope.event.created_at,
280        event_id: envelope.hash.clone(),
281    })
282}
283
284/// Durable ordering check for a new update (Agent Profile Section 6): its
285/// nonce must exceed the nonce of the latest accepted update for the same
286/// Agent ID, independent of the replay cache.
287pub fn validate_profile_succession(
288    envelope: &Envelope<ProfileUpdatePayload>,
289    latest_nonce: Option<u64>,
290) -> Result<()> {
291    match latest_nonce {
292        Some(latest) if envelope.event.nonce <= latest => {
293            Err(SdkError::NonceNotGreater { max_nonce: latest })
294        }
295        _ => Ok(()),
296    }
297}
298
299/// Selects the latest profile state from accepted update envelopes. Nonces
300/// are strictly monotonic per Agent ID, so the latest profile is defined as
301/// the accepted `profile.update` with the greatest `nonce` — deterministic and
302/// independently checkable from event history alone.
303pub fn latest_profile_update(
304    envelopes: &[Envelope<ProfileUpdatePayload>],
305) -> Option<&Envelope<ProfileUpdatePayload>> {
306    envelopes.iter().max_by_key(|envelope| envelope.event.nonce)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::delegation::PrincipalDescriptor;
313    use crate::identity::AgentSigner;
314
315    #[test]
316    fn materializes_valid_profile_update() {
317        let signer = AgentSigner::from_seed([11; 32]);
318        let mut payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
319        payload.capabilities = Some(vec!["research".to_owned()]);
320        payload.extra = Some(BTreeMap::from([(
321            "domain".to_owned(),
322            Value::String("research".to_owned()),
323        )]));
324        payload.links = Some(vec![ProfileLink {
325            name: "Homepage".to_owned(),
326            url: "https://example.com".to_owned(),
327            rel: link_rel::HOMEPAGE.to_owned(),
328        }]);
329        let mut principal = PrincipalDescriptor::new("https://api.al.ink/d9c6a99cne5g00a6scn0");
330        principal.kind = Some("person".to_owned());
331        principal.name = Some("Yan".to_owned());
332        payload.delegations = Some(vec![ProfileDelegationHint {
333            id: Some("del_1".to_owned()),
334            principal,
335            relationship: Some("primary_delegate".to_owned()),
336            scopes: Some(vec!["inbox.screen".to_owned()]),
337        }]);
338        let expected_extra = payload.extra.clone();
339        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
340        let envelope = signer.sign_event(event).unwrap();
341
342        let profile = materialize_profile(&envelope).unwrap();
343
344        assert_eq!(profile.id, signer.agent_id());
345        assert_eq!(profile.name, "ResearchAgent-v3");
346        let links = profile.links.as_deref().unwrap();
347        assert_eq!(links.len(), 1);
348        assert_eq!(links[0].rel, link_rel::HOMEPAGE);
349        assert_eq!(profile.delegations.as_deref().map(<[_]>::len), Some(1));
350        assert_eq!(profile.extra, expected_extra);
351        assert_eq!(profile.updated_at, 1_779_753_600_000);
352        assert_eq!(profile.event_id, envelope.hash);
353    }
354
355    #[test]
356    fn materialized_profile_has_no_username_field() {
357        let signer = AgentSigner::from_seed([15; 32]);
358        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent-v3");
359        let event = profile_update_event(signer.agent_id(), 1_779_753_600_002, 1, payload);
360        let envelope = signer.sign_event(event).unwrap();
361
362        let profile = materialize_profile(&envelope).unwrap();
363
364        assert_eq!(profile.id, signer.agent_id());
365        let value = serde_json::to_value(&profile).unwrap();
366        assert!(value.get("username").is_none());
367    }
368
369    #[test]
370    fn latest_profile_update_picks_greatest_nonce() {
371        let signer = AgentSigner::from_seed([16; 32]);
372        let envelopes: Vec<_> = [3_u64, 1, 2]
373            .into_iter()
374            .map(|nonce| {
375                let payload =
376                    ProfileUpdatePayload::new(signer.agent_id(), format!("Agent-v{nonce}"));
377                signer
378                    .sign_event(profile_update_event(
379                        signer.agent_id(),
380                        1_779_753_600_000 + nonce as i64,
381                        nonce,
382                        payload,
383                    ))
384                    .unwrap()
385            })
386            .collect();
387
388        assert!(latest_profile_update(&[]).is_none());
389        let latest = latest_profile_update(&envelopes).unwrap();
390        assert_eq!(latest.event.nonce, 3);
391        assert_eq!(materialize_profile(latest).unwrap().name, "Agent-v3");
392    }
393
394    #[test]
395    fn requires_nonce_succession_and_closed_event_fields() {
396        let signer = AgentSigner::from_seed([17; 32]);
397        let payload = ProfileUpdatePayload::new(signer.agent_id(), "A");
398        let envelope = signer
399            .sign_event(profile_update_event(
400                signer.agent_id(),
401                1_000,
402                7,
403                payload.clone(),
404            ))
405            .unwrap();
406        validate_profile_succession(&envelope, None).unwrap();
407        validate_profile_succession(&envelope, Some(6)).unwrap();
408        for latest in [7, 8] {
409            assert!(matches!(
410                validate_profile_succession(&envelope, Some(latest)),
411                Err(SdkError::NonceNotGreater { max_nonce }) if max_nonce == latest
412            ));
413        }
414        let extra = signer
415            .sign_event(
416                profile_update_event(signer.agent_id(), 1_000, 8, payload).with_room_id("r"),
417            )
418            .unwrap();
419        assert_eq!(
420            validate_profile_update(&extra).unwrap_err().code(),
421            Some("invalid_event")
422        );
423    }
424
425    #[test]
426    fn rejects_actor_payload_mismatch() {
427        let signer = AgentSigner::from_seed([12; 32]);
428        let other = AgentSigner::from_seed([13; 32]);
429        let payload = ProfileUpdatePayload::new(other.agent_id(), "Imposter");
430        let event = profile_update_event(signer.agent_id(), 1_779_753_600_000, 1, payload);
431        let envelope = signer.sign_event(event).unwrap();
432
433        assert!(matches!(
434            validate_profile_update(&envelope),
435            Err(SdkError::InvalidActor(_))
436        ));
437    }
438
439    #[test]
440    fn rejects_legacy_agent_id_payload_without_id() {
441        let signer = AgentSigner::from_seed([14; 32]);
442        let legacy_event = crate::identity::Event::new(
443            PROTOCOL,
444            PROFILE_UPDATE,
445            signer.agent_id(),
446            1_779_753_600_001,
447            1,
448            serde_json::json!({
449                "agent_id": signer.agent_id(),
450                "name": "LegacyAgent"
451            }),
452        );
453        let signed = signer.sign_event(legacy_event).unwrap();
454
455        assert!(serde_json::from_value::<Envelope<ProfileUpdatePayload>>(
456            serde_json::to_value(signed).unwrap()
457        )
458        .is_err());
459    }
460
461    #[test]
462    fn rejects_wrong_protocol_and_type() {
463        let signer = AgentSigner::from_seed([19; 32]);
464        let payload = ProfileUpdatePayload::new(signer.agent_id(), "ResearchAgent");
465
466        let wrong_protocol = signer
467            .sign_event(crate::identity::Event::new(
468                "agent-discourse/1.0",
469                PROFILE_UPDATE,
470                signer.agent_id(),
471                1_779_753_600_000,
472                1,
473                payload.clone(),
474            ))
475            .unwrap();
476        assert!(matches!(
477            validate_profile_update(&wrong_protocol),
478            Err(SdkError::InvalidEventProtocol { .. })
479        ));
480
481        let wrong_type = signer
482            .sign_event(crate::identity::Event::new(
483                PROTOCOL,
484                "profile.delete",
485                signer.agent_id(),
486                1_779_753_600_000,
487                1,
488                payload,
489            ))
490            .unwrap();
491        assert!(matches!(
492            validate_profile_update(&wrong_type),
493            Err(SdkError::InvalidEventType { .. })
494        ));
495    }
496}