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};
9use crate::object::{AnnotationKind, ContentHash, StateId};
10
11pub const CONTEXT_FORMAT: &str = "heddle-context-revision-v2";
12
13/// Evidence captured by the authoring context command. This remains inside the
14/// signed native operation even when a hosted view does not project every
15/// field.
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct ContextProvenance {
19    pub revision_id: String,
20    pub kind: AnnotationKind,
21    pub attribution: String,
22    pub source_hash: Option<ContentHash>,
23    pub created_at_state: Option<StateId>,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct ContextRevision {
29    pub version: u16,
30    pub id: Uuid,
31    /// Original outer operation IDs, never mutable observation versions.
32    pub parents: Vec<ContentHash>,
33    pub metadata: CollaborationMetadata,
34    pub anchor: CollaborationAnchor,
35    pub content: String,
36    pub tags: Vec<super::AnnotationTag>,
37    pub supersedes: Option<Uuid>,
38    pub extracted_from: Option<DiscussionRecordId>,
39    pub occurred_at_ms: i64,
40    /// Absent only on native records authored before provenance was carried in
41    /// the signed body. New local replication must always populate it.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub provenance: Option<ContextProvenance>,
44}
45impl ContextRevision {
46    pub fn encode(&self) -> Result<Vec<u8>, CollaborationCodecError> {
47        self.metadata.validate()?;
48        super::validate_annotation_tags(&self.tags)?;
49        super::operation::validate_anchor(&self.anchor)?;
50        if self.version != 2
51            || self.id.is_nil()
52            || self
53                .supersedes
54                .is_some_and(|id| id.is_nil() || id == self.id)
55            || self.content.trim().is_empty()
56            || self.content.len() > 256 * 1024
57            || self.parents.len() > 128
58            || self.parents.windows(2).any(|pair| pair[0] >= pair[1])
59        {
60            return Err(CollaborationCodecError::Invalid(
61                "invalid or unbounded context revision".into(),
62            ));
63        }
64        rmp_serde::to_vec_named(self).map_err(|e| CollaborationCodecError::Encoding(e.to_string()))
65    }
66    pub fn decode(bytes: &[u8]) -> Result<Self, CollaborationCodecError> {
67        if bytes.len() > 512 * 1024 {
68            return Err(CollaborationCodecError::Invalid(
69                "context revision exceeds record bound".into(),
70            ));
71        }
72        let record: Self = rmp_serde::from_slice(bytes)
73            .map_err(|e| CollaborationCodecError::Decoding(e.to_string()))?;
74        if record.encode()? != bytes {
75            return Err(CollaborationCodecError::Invalid(
76                "context revision is not canonical".into(),
77            ));
78        }
79        Ok(record)
80    }
81    pub fn id(&self) -> Result<ContentHash, CollaborationCodecError> {
82        Ok(ContentHash::compute_typed(CONTEXT_FORMAT, &self.encode()?))
83    }
84}