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}
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        assert_eq!(
207            decoded.operation.id().expect("id reuses decoded bytes"),
208            decoded.operation_id
209        );
210        assert_eq!(
211            decoded.operation.id().expect("cached id"),
212            CollabOpId::for_bytes(&decoded.operation.encode().expect("re-encode"))
213        );
214        let mut changed = original.clone();
215        changed.metadata.as_mut().expect("metadata").actor.agent_id = Some("another-agent".into());
216        let changed_id = changed.id().expect("changed actor");
217        assert_ne!(
218            decoded.operation_id, changed_id,
219            "author binding must change when agent changes"
220        );
221        assert_eq!(
222            changed_id,
223            CollabOpId::for_bytes(&changed.encode().expect("changed actor"))
224        );
225        changed = original.clone();
226        changed
227            .metadata
228            .as_mut()
229            .expect("metadata")
230            .mentions
231            .clear();
232        let changed_id = changed.id().expect("removed mention");
233        assert_ne!(
234            decoded.operation_id, changed_id,
235            "mentions belong to the author-signed identity"
236        );
237        assert_eq!(
238            changed_id,
239            CollabOpId::for_bytes(&changed.encode().expect("removed mention"))
240        );
241        changed = original;
242        changed.metadata.as_mut().expect("metadata").scope.spool = Uuid::nil();
243        assert!(
244            changed.encode().is_err(),
245            "invalid scope cannot become a canonical operation"
246        );
247    }
248}