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}
90impl CollaborationMetadata {
91 pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
92 if self.scope.spool.is_nil()
93 || self.actor.principal_id.is_nil()
94 || self.mentions.len() > 128
95 {
96 return Err(invalid(
97 "collaboration requires non-nil scope/actor and at most 128 mentions",
98 ));
99 }
100 if self.actor.agent_id.as_ref().is_some_and(|id| {
101 id.trim().is_empty() || id.len() > 512 || id.chars().any(char::is_control)
102 }) {
103 return Err(invalid("invalid stable collaboration agent identity"));
104 }
105 for mention in &self.mentions {
106 mention.validate()?;
107 }
108 Ok(())
109 }
110}
111impl CollaborationMention {
112 pub fn validate(&self) -> Result<(), CollaborationCodecError> {
113 match self {
114 CollaborationMention::Spool { spool }
115 | CollaborationMention::Thread { spool, .. }
116 | CollaborationMention::State { spool, .. } => {
117 if spool.is_nil() {
118 return Err(invalid("mention spool cannot be nil"));
119 }
120 }
121 CollaborationMention::Record { spool, id, .. } => {
122 if spool.is_some_and(|id| id.is_nil())
123 || id.trim().is_empty()
124 || id.len() > 1024
125 || id.chars().any(char::is_control)
126 {
127 return Err(invalid("invalid stable mention identity"));
128 }
129 }
130 CollaborationMention::Checkout { spool, id, .. } => {
131 if spool.is_nil()
132 || id.trim().is_empty()
133 || id.len() > 1024
134 || id.chars().any(char::is_control)
135 {
136 return Err(invalid("invalid stable mention identity"));
137 }
138 }
139 CollaborationMention::GitCommit { spool, oid } => {
140 if spool.is_nil()
141 || !matches!(oid.len(), 40 | 64)
142 || !oid
143 .bytes()
144 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
145 {
146 return Err(invalid(
147 "Git mention requires exact lower-case commit identity",
148 ));
149 }
150 }
151 CollaborationMention::Device { .. } => {}
152 }
153 Ok(())
154 }
155}
156
157fn invalid(message: &str) -> CollaborationCodecError {
158 CollaborationCodecError::Invalid(message.into())
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::object::{
165 Attribution, CollabOpId, CollaborationIdempotencyKey, CollaborationOperationBodyV1,
166 CollaborationOperationEnvelope, DiscussionRecordId, DiscussionTurnV1, Principal,
167 };
168 fn operation() -> CollaborationOperationEnvelope {
169 CollaborationOperationEnvelope::new(
170 DiscussionRecordId::generate(),
171 vec![CollabOpId::from_bytes([9; 32])],
172 CollaborationIdempotencyKey::new("append-1").expect("operation identity"),
173 Attribution::human(Principal::new("display name", "")),
174 1,
175 CollaborationOperationBodyV1::AppendTurn {
176 turn: DiscussionTurnV1::new("See the exact reviewed state").expect("turn"),
177 },
178 )
179 .expect("operation")
180 .with_metadata(CollaborationMetadata {
181 scope: CollaborationScope {
182 spool: Uuid::from_u128(1),
183 thread: Some(ContentHash::from_bytes([3; 32])),
184 },
185 actor: CollaborationActor {
186 principal_id: Uuid::from_u128(2),
187 agent_id: Some("agent-device-7".into()),
188 },
189 mentions: vec![CollaborationMention::State {
190 spool: Uuid::from_u128(1),
191 state: StateId::from_bytes([4; 32]),
192 }],
193 })
194 .expect("portable actor and mention")
195 }
196 #[test]
197 fn signed_record_identity_retains_and_binds_stable_actor_scope_and_mentions() {
198 let original = operation();
199 let bytes = original.encode().expect("canonical operation");
200 let decoded = CollaborationOperationEnvelope::decode(&bytes).expect("portable decode");
201 assert_eq!(
202 decoded.operation.metadata, original.metadata,
203 "actor and references must survive synchronization"
204 );
205 assert_eq!(decoded.operation, original);
206 let mut changed = original.clone();
207 changed.metadata.as_mut().expect("metadata").actor.agent_id = Some("another-agent".into());
208 assert_ne!(
209 decoded.operation_id,
210 CollabOpId::for_bytes(&changed.encode().expect("changed actor")),
211 "author binding must change when agent changes"
212 );
213 changed = original.clone();
214 changed
215 .metadata
216 .as_mut()
217 .expect("metadata")
218 .mentions
219 .clear();
220 assert_ne!(
221 decoded.operation_id,
222 CollabOpId::for_bytes(&changed.encode().expect("removed mention")),
223 "mentions belong to the author-signed identity"
224 );
225 changed = original;
226 changed.metadata.as_mut().expect("metadata").scope.spool = Uuid::nil();
227 assert!(
228 changed.encode().is_err(),
229 "invalid scope cannot become a canonical operation"
230 );
231 }
232}