agent-workspace-contract 0.5.0

Transport-neutral contracts for Agent Infra workspace APIs
Documentation
use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};

/// Resource owner identity. At least one owner is required on every Environment
/// resource; callers authenticate with runtime, agent, or user credentials.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OwnerKind {
    Runtime,
    Agent,
    User,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct OwnerRef {
    pub kind: OwnerKind,
    pub id: String,
}

impl OwnerRef {
    pub fn runtime(id: impl Into<String>) -> Self {
        Self {
            kind: OwnerKind::Runtime,
            id: id.into(),
        }
    }

    pub fn agent(id: impl Into<String>) -> Self {
        Self {
            kind: OwnerKind::Agent,
            id: id.into(),
        }
    }

    pub fn user(id: impl Into<String>) -> Self {
        Self {
            kind: OwnerKind::User,
            id: id.into(),
        }
    }

    pub fn from_principal(principal: &crate::PrincipalRef) -> Self {
        match principal.kind {
            crate::PrincipalKind::User => Self::user(&principal.id),
            crate::PrincipalKind::Agent => Self::agent(&principal.id),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct PatchOwnersRequest {
    pub expected_version: u64,
    #[serde(default)]
    pub add: Vec<OwnerRef>,
    #[serde(default)]
    pub remove: Vec<OwnerRef>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OwnerValidationError {
    Empty,
    Duplicate,
    NotSubset,
}

/// Normalize owners: non-empty, deduplicated, stable order.
pub fn normalize_owners(owners: Vec<OwnerRef>) -> Result<Vec<OwnerRef>, OwnerValidationError> {
    if owners.is_empty() {
        return Err(OwnerValidationError::Empty);
    }
    let mut set = BTreeSet::new();
    for owner in owners {
        if !set.insert(owner.clone()) {
            return Err(OwnerValidationError::Duplicate);
        }
    }
    Ok(set.into_iter().collect())
}

/// True when `candidate` is a subset of `parent` (every element of candidate in parent).
pub fn owners_subset_of(candidate: &[OwnerRef], parent: &[OwnerRef]) -> bool {
    let parent_set: BTreeSet<_> = parent.iter().collect();
    candidate.iter().all(|owner| parent_set.contains(owner))
}

/// True when caller and resource owners share at least one identity.
pub fn owners_intersect(caller: &[OwnerRef], resource: &[OwnerRef]) -> bool {
    let resource_set: BTreeSet<_> = resource.iter().collect();
    caller.iter().any(|owner| resource_set.contains(owner))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_rejects_empty() {
        assert!(matches!(
            normalize_owners(vec![]),
            Err(OwnerValidationError::Empty)
        ));
    }

    #[test]
    fn normalize_rejects_duplicate() {
        assert!(matches!(
            normalize_owners(vec![OwnerRef::runtime("rt-1"), OwnerRef::runtime("rt-1"),]),
            Err(OwnerValidationError::Duplicate)
        ));
    }

    #[test]
    fn subset_and_intersect() {
        let parent = vec![OwnerRef::user("u1"), OwnerRef::agent("agt-1")];
        let child = vec![OwnerRef::agent("agt-1")];
        assert!(owners_subset_of(&child, &parent));
        assert!(!owners_subset_of(&parent, &child));
        assert!(owners_intersect(
            &[OwnerRef::runtime("rt-1")],
            &[OwnerRef::runtime("rt-1"), OwnerRef::user("u1")]
        ));
        assert!(!owners_intersect(
            &[OwnerRef::runtime("rt-2")],
            &[OwnerRef::runtime("rt-1")]
        ));
    }
}