heddle_object_model/object/thread_replication/metadata/
audience.rs1use 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 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 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 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
91pub 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}