Skip to main content

heddle_object_model/object/collaboration/
context.rs

1//! Immutable context revisions, sharing the same portable actor and scope as
2//! discussions. The record ID addresses history; the operation ID names a revision.
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::{
7    CollaborationAnchor, CollaborationCodecError, CollaborationMetadata, DiscussionRecordId,
8    canonical_body::CanonicalBody,
9};
10use crate::object::{AnnotationKind, ContentHash, StateId};
11
12pub const CONTEXT_FORMAT: &str = "heddle-context-revision-v2";
13
14/// Evidence captured by the authoring context command. This remains inside the
15/// signed native operation even when a hosted view does not project every
16/// field.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ContextProvenance {
20    pub revision_id: String,
21    pub kind: AnnotationKind,
22    pub attribution: String,
23    pub source_hash: Option<ContentHash>,
24    pub created_at_state: Option<StateId>,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct ContextRevision {
30    pub version: u16,
31    pub id: Uuid,
32    /// Original outer operation IDs, never mutable observation versions.
33    pub parents: Vec<ContentHash>,
34    pub metadata: CollaborationMetadata,
35    pub anchor: CollaborationAnchor,
36    pub content: String,
37    pub tags: Vec<super::AnnotationTag>,
38    pub supersedes: Option<Uuid>,
39    pub extracted_from: Option<DiscussionRecordId>,
40    pub occurred_at_ms: i64,
41    /// Absent only on native records authored before provenance was carried in
42    /// the signed body. New local replication must always populate it.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub provenance: Option<ContextProvenance>,
45    /// Canonical MessagePack from the last successful [`Self::encode`] or [`Self::decode`].
46    /// Not serialized. Struct literals use [`Default::default`].
47    #[serde(skip)]
48    pub canonical_body: CanonicalBody,
49}
50impl ContextRevision {
51    fn encode_fields(&self) -> Result<Vec<u8>, CollaborationCodecError> {
52        self.metadata.validate()?;
53        super::validate_annotation_tags(&self.tags)?;
54        super::operation::validate_anchor(&self.anchor)?;
55        if self.version != 2
56            || self.id.is_nil()
57            || self
58                .supersedes
59                .is_some_and(|id| id.is_nil() || id == self.id)
60            || self.content.trim().is_empty()
61            || self.content.len() > 256 * 1024
62            || self.parents.len() > 128
63            || self.parents.windows(2).any(|pair| pair[0] >= pair[1])
64        {
65            return Err(CollaborationCodecError::Invalid(
66                "invalid or unbounded context revision".into(),
67            ));
68        }
69        rmp_serde::to_vec_named(self).map_err(|e| CollaborationCodecError::Encoding(e.to_string()))
70    }
71    pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
72        self.canonical_body.clear();
73        let bytes = self.encode_fields()?;
74        self.canonical_body.store(bytes.clone());
75        Ok(bytes)
76    }
77    pub fn decode(bytes: &[u8]) -> Result<Self, CollaborationCodecError> {
78        if bytes.len() > 512 * 1024 {
79            return Err(CollaborationCodecError::Invalid(
80                "context revision exceeds record bound".into(),
81            ));
82        }
83        let record: Self = rmp_serde::from_slice(bytes)
84            .map_err(|e| CollaborationCodecError::Decoding(e.to_string()))?;
85        if record.encode()? != bytes {
86            return Err(CollaborationCodecError::Invalid(
87                "context revision is not canonical".into(),
88            ));
89        }
90        Ok(record)
91    }
92    pub fn id(&self) -> Result<ContentHash, CollaborationCodecError> {
93        if let Some(bytes) = self.canonical_body.cloned() {
94            CanonicalBody::debug_matches(&bytes, || self.encode_fields());
95            return Ok(ContentHash::compute_typed(CONTEXT_FORMAT, &bytes));
96        }
97        Ok(ContentHash::compute_typed(CONTEXT_FORMAT, &self.encode()?))
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use uuid::Uuid;
104
105    use super::*;
106    use crate::object::{
107        CollaborationActor, CollaborationAnchor, CollaborationMetadata, CollaborationScope,
108        ContentHash,
109    };
110
111    fn sample() -> ContextRevision {
112        ContextRevision {
113            version: 2,
114            id: Uuid::from_u128(9),
115            parents: Vec::new(),
116            metadata: CollaborationMetadata {
117                scope: CollaborationScope {
118                    spool: Uuid::from_u128(1),
119                    thread: None,
120                },
121                actor: CollaborationActor {
122                    principal_id: Uuid::from_u128(2),
123                    agent_id: None,
124                },
125                mentions: Vec::new(),
126            },
127            anchor: CollaborationAnchor::Repository,
128            content: "rationale".into(),
129            tags: Vec::new(),
130            supersedes: None,
131            extracted_from: None,
132            occurred_at_ms: 10,
133            provenance: None,
134            canonical_body: Default::default(),
135        }
136    }
137
138    #[test]
139    fn id_matches_reencode_of_canonical_body() {
140        let revision = sample();
141        let bytes = revision.encode().expect("canonical revision");
142        let old = ContentHash::compute_typed(CONTEXT_FORMAT, &bytes);
143        assert_eq!(revision.id().expect("fresh id"), old);
144        assert_eq!(revision.id().expect("cached id"), old);
145
146        let decoded = ContextRevision::decode(&bytes).expect("decode");
147        assert_eq!(decoded, revision);
148        assert_eq!(decoded.id().expect("decoded id"), old);
149        assert_eq!(
150            decoded.id().expect("decoded id"),
151            ContentHash::compute_typed(CONTEXT_FORMAT, &decoded.encode().expect("re-encode"))
152        );
153
154        let mut changed = revision.clone();
155        changed.content = "other rationale".into();
156        let changed_id = changed.id().expect("changed id");
157        assert_ne!(changed_id, old);
158        assert_eq!(
159            changed_id,
160            ContentHash::compute_typed(CONTEXT_FORMAT, &changed.encode().expect("changed"))
161        );
162    }
163}