Skip to main content

heddle_object_model/object/thread_replication/metadata/
audience.rs

1//! Audience selection is independent of replication destinations and retention.
2//! Matching an audience never substitutes for current capability authorization.
3use std::collections::BTreeSet;
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use super::super::invalid;
9use crate::error::Result;
10
11#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct Invitee {
14    pub principal_id: Uuid,
15    /// None includes the principal's otherwise-authorized delegates. Some
16    /// restricts this invitation to one exactly attributed agent.
17    pub agent_id: Option<String>,
18}
19
20#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "kind", content = "invitees", rename_all = "snake_case")]
22pub enum Audience {
23    #[default]
24    Owner,
25    Invited(BTreeSet<Invitee>),
26    /// Uses the Spool's current audience, never a cached membership snapshot.
27    Spool,
28}
29
30impl Audience {
31    pub fn validate(&self) -> Result<()> {
32        if let Self::Invited(invitees) = self {
33            if invitees.is_empty() || invitees.len() > 128 {
34                return Err(invalid("Thread audience requires 1..128 invitees"));
35            }
36            for invitee in invitees {
37                if invitee.principal_id.is_nil()
38                    || invitee
39                        .agent_id
40                        .as_ref()
41                        .is_some_and(|agent| agent.trim().is_empty() || agent.len() > 256)
42                {
43                    return Err(invalid("invalid Thread audience invitee"));
44                }
45            }
46        }
47        Ok(())
48    }
49
50    /// The owner account comes from verified original genesis admission, not
51    /// the uploading principal, Spool owner, or current owner-key selector.
52    pub fn includes(
53        &self,
54        owner: Uuid,
55        principal: Uuid,
56        agent_id: Option<&str>,
57        in_spool_audience: bool,
58    ) -> bool {
59        if owner.is_nil() || principal.is_nil() {
60            return false;
61        }
62        if principal == owner {
63            return true;
64        }
65        self.includes_non_owner(principal, agent_id, in_spool_audience)
66    }
67
68    pub fn includes_non_owner(
69        &self,
70        principal: Uuid,
71        agent_id: Option<&str>,
72        in_spool_audience: bool,
73    ) -> bool {
74        if principal.is_nil() {
75            return false;
76        }
77        match self {
78            Self::Owner => false,
79            Self::Spool => in_spool_audience,
80            Self::Invited(invitees) => invitees.iter().any(|invitee| {
81                invitee.principal_id == principal
82                    && invitee
83                        .agent_id
84                        .as_deref()
85                        .is_none_or(|id| Some(id) == agent_id)
86            }),
87        }
88    }
89}
90
91/// Concurrent audience changes do not union grants. Access must satisfy every
92/// unresolved candidate; an empty frontier has the owner-only default.
93pub fn includes_frontier<'a>(
94    candidates: impl IntoIterator<Item = &'a Audience>,
95    owner: Uuid,
96    principal: Uuid,
97    agent_id: Option<&str>,
98    in_spool_audience: bool,
99) -> bool {
100    let mut candidates = candidates.into_iter().peekable();
101    if candidates.peek().is_none() {
102        return Audience::Owner.includes(owner, principal, agent_id, in_spool_audience);
103    }
104    candidates.all(|policy| policy.includes(owner, principal, agent_id, in_spool_audience))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn spool_membership_never_opens_owner_or_invited_audiences() {
113        let owner = Uuid::from_u128(1);
114        let guest = Uuid::from_u128(2);
115        let other = Uuid::from_u128(3);
116        let invited = Audience::Invited(BTreeSet::from([Invitee {
117            principal_id: guest,
118            agent_id: Some("reviewer".into()),
119        }]));
120        assert!(!Audience::Owner.includes(owner, guest, None, true));
121        assert!(!invited.includes(owner, other, Some("reviewer"), true));
122        assert!(!invited.includes(owner, guest, Some("different-agent"), true));
123        assert!(!invited.includes(owner, guest, None, true));
124        assert!(invited.includes(owner, guest, Some("reviewer"), false));
125        assert!(Audience::Owner.includes(owner, owner, Some("delegated-agent"), false));
126        assert!(!Audience::Spool.includes(owner, guest, None, false));
127        assert!(Audience::Spool.includes(owner, guest, None, true));
128    }
129
130    #[test]
131    fn concurrent_audiences_intersect_and_missing_policy_is_owner_only() {
132        let owner = Uuid::from_u128(1);
133        let guest = Uuid::from_u128(2);
134        assert!(!includes_frontier([], owner, guest, None, true));
135        assert!(includes_frontier([], owner, owner, None, false));
136        assert!(!includes_frontier(
137            [&Audience::Spool, &Audience::Owner],
138            owner,
139            guest,
140            None,
141            true,
142        ));
143        assert!(includes_frontier(
144            [&Audience::Spool, &Audience::Owner],
145            owner,
146            owner,
147            None,
148            false,
149        ));
150        assert!(!includes_frontier([], Uuid::nil(), Uuid::nil(), None, true));
151    }
152}