Skip to main content

heddle_object_model/object/collaboration/
metadata.rs

1//! Portable identity and references carried by the signed collaboration record.
2//! Display attribution is retained separately; it is never an account binding.
3use 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}