heddle_object_model/object/collaboration/
metadata.rs1use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::CollaborationCodecError;
7use crate::object::{ContentHash, StateId};
8
9#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub struct CollaborationScope {
11 pub spool: Uuid,
12 pub thread: Option<ContentHash>,
13}
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15pub struct CollaborationActor {
16 pub principal_id: Uuid,
17 pub agent_id: Option<String>,
18}
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct CollaborationMetadata {
21 pub scope: CollaborationScope,
22 pub actor: CollaborationActor,
23 pub mentions: Vec<CollaborationMention>,
24}
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum CollaborationRecordKind {
28 Discussion,
29 Context,
30 Operation,
31 Run,
32 Policy,
33 Analysis,
34 Invitation,
35 Grant,
36 DiscussionTurn,
37 Review,
38 Notification,
39 AttentionItem,
40 Member,
41 ApprovalGroup,
42 Session,
43 SignupInvitation,
44 TimelineEvent,
45 Artifact,
46 Mount,
47 SupportAccess,
48 DeviceRecord,
49 Delegation,
50 Recovery,
51 OwnerTransition,
52 Billing,
53 Evidence,
54 CheckAcknowledgement,
55 ProviderConnection,
56 RemoteLink,
57}
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case", tag = "kind")]
60pub enum CollaborationMention {
61 Spool {
62 spool: Uuid,
63 },
64 Thread {
65 spool: Uuid,
66 thread: ContentHash,
67 },
68 State {
69 spool: Uuid,
70 state: StateId,
71 },
72 GitCommit {
73 spool: Uuid,
74 oid: String,
75 },
76 Checkout {
77 spool: Uuid,
78 device: [u8; 32],
79 id: String,
80 },
81 Record {
82 spool: Option<Uuid>,
83 record_kind: CollaborationRecordKind,
84 id: String,
85 },
86 Device {
87 key: [u8; 32],
88 },
89 Principal {
90 principal_id: Uuid,
91 },
92 Source {
93 target: Box<super::AnnotationSourceReference>,
94 },
95}
96impl CollaborationMetadata {
97 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
98 if self.scope.spool.is_nil()
99 || self.actor.principal_id.is_nil()
100 || self.mentions.len() > 128
101 {
102 return Err(invalid(
103 "collaboration requires non-nil scope/actor and at most 128 mentions",
104 ));
105 }
106 if self.actor.agent_id.as_ref().is_some_and(|id| {
107 id.trim().is_empty() || id.len() > 512 || id.chars().any(char::is_control)
108 }) {
109 return Err(invalid("invalid stable collaboration agent identity"));
110 }
111 let bytes = rmp_serde::to_vec_named(&self.mentions)
112 .map_err(|error| CollaborationCodecError::Encoding(error.to_string()))?;
113 if bytes.len() > 256 * 1024 {
114 return Err(invalid("canonical mentions exceed 256 KiB"));
115 }
116 for mention in &self.mentions {
117 mention.validate()?;
118 }
119 Ok(())
120 }
121}
122impl CollaborationMention {
123 pub fn validate(&self) -> Result<(), CollaborationCodecError> {
124 match self {
125 CollaborationMention::Spool { spool }
126 | CollaborationMention::Thread { spool, .. }
127 | CollaborationMention::State { spool, .. } => {
128 if spool.is_nil() {
129 return Err(invalid("mention spool cannot be nil"));
130 }
131 }
132 CollaborationMention::Record { spool, id, .. } => {
133 if spool.is_some_and(|id| id.is_nil())
134 || id.trim().is_empty()
135 || id.len() > 1024
136 || id.chars().any(char::is_control)
137 {
138 return Err(invalid("invalid stable mention identity"));
139 }
140 }
141 CollaborationMention::Checkout { spool, id, .. } => {
142 if spool.is_nil()
143 || id.trim().is_empty()
144 || id.len() > 1024
145 || id.chars().any(char::is_control)
146 {
147 return Err(invalid("invalid stable mention identity"));
148 }
149 }
150 CollaborationMention::GitCommit { spool, oid } => {
151 if spool.is_nil()
152 || !matches!(oid.len(), 40 | 64)
153 || !oid
154 .bytes()
155 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
156 {
157 return Err(invalid(
158 "Git mention requires exact lower-case commit identity",
159 ));
160 }
161 }
162 CollaborationMention::Device { .. } => {}
163 CollaborationMention::Principal { principal_id } => {
164 if principal_id.is_nil() {
165 return Err(invalid("principal mention cannot be nil"));
166 }
167 }
168 CollaborationMention::Source { target } => {
169 target.validate()?;
170 if target.source.target.is_some() {
171 return Err(invalid("source mentions require pinned coordinates"));
172 }
173 }
174 }
175 Ok(())
176 }
177}
178
179fn invalid(message: &str) -> CollaborationCodecError {
180 CollaborationCodecError::Invalid(message.into())
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::object::{
187 Attribution, CollabOpId, CollaborationIdempotencyKey, CollaborationOperationBodyV1,
188 CollaborationOperationEnvelope, DiscussionRecordId, DiscussionTurnV1, Principal,
189 };
190 fn operation() -> CollaborationOperationEnvelope {
191 CollaborationOperationEnvelope::new(
192 DiscussionRecordId::generate(),
193 vec![CollabOpId::from_bytes([9; 32])],
194 CollaborationIdempotencyKey::new("append-1").expect("operation identity"),
195 Attribution::human(Principal::new("display name", "")),
196 1,
197 CollaborationOperationBodyV1::AppendTurn {
198 turn: DiscussionTurnV1::new("See the exact reviewed state").expect("turn"),
199 },
200 )
201 .expect("operation")
202 .with_metadata(CollaborationMetadata {
203 scope: CollaborationScope {
204 spool: Uuid::from_u128(1),
205 thread: Some(ContentHash::from_bytes([3; 32])),
206 },
207 actor: CollaborationActor {
208 principal_id: Uuid::from_u128(2),
209 agent_id: Some("agent-device-7".into()),
210 },
211 mentions: vec![CollaborationMention::State {
212 spool: Uuid::from_u128(1),
213 state: StateId::from_bytes([4; 32]),
214 }],
215 })
216 .expect("portable actor and mention")
217 }
218 #[test]
219 fn signed_record_identity_retains_and_binds_stable_actor_scope_and_mentions() {
220 let original = operation();
221 let bytes = original.encode().expect("canonical operation");
222 let decoded = CollaborationOperationEnvelope::decode(&bytes).expect("portable decode");
223 assert_eq!(
224 decoded.operation.metadata, original.metadata,
225 "actor and references must survive synchronization"
226 );
227 assert_eq!(decoded.operation, original);
228 assert_eq!(
229 decoded.operation.id().expect("id reuses decoded bytes"),
230 decoded.operation_id
231 );
232 assert_eq!(
233 decoded.operation.id().expect("cached id"),
234 CollabOpId::for_bytes(&decoded.operation.encode().expect("re-encode"))
235 );
236 let mut changed = original.clone();
237 changed.metadata.as_mut().expect("metadata").actor.agent_id = Some("another-agent".into());
238 let changed_id = changed.id().expect("changed actor");
239 assert_ne!(
240 decoded.operation_id, changed_id,
241 "author binding must change when agent changes"
242 );
243 assert_eq!(
244 changed_id,
245 CollabOpId::for_bytes(&changed.encode().expect("changed actor"))
246 );
247 changed = original.clone();
248 changed
249 .metadata
250 .as_mut()
251 .expect("metadata")
252 .mentions
253 .clear();
254 let changed_id = changed.id().expect("removed mention");
255 assert_ne!(
256 decoded.operation_id, changed_id,
257 "mentions belong to the author-signed identity"
258 );
259 assert_eq!(
260 changed_id,
261 CollabOpId::for_bytes(&changed.encode().expect("removed mention"))
262 );
263 changed = original;
264 changed.metadata.as_mut().expect("metadata").scope.spool = Uuid::nil();
265 assert!(
266 changed.encode().is_err(),
267 "invalid scope cannot become a canonical operation"
268 );
269 }
270}