heddle-object-model 0.24.3

Heddle's content-addressed object model and stable codecs.
Documentation
//! Portable identity and references carried by the signed collaboration record.
//! Display attribution is retained separately; it is never an account binding.
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use super::CollaborationCodecError;
use crate::object::{ContentHash, StateId};

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollaborationScope {
    pub spool: Uuid,
    pub thread: Option<ContentHash>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollaborationActor {
    pub principal_id: Uuid,
    pub agent_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CollaborationMetadata {
    pub scope: CollaborationScope,
    pub actor: CollaborationActor,
    pub mentions: Vec<CollaborationMention>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CollaborationRecordKind {
    Discussion,
    Context,
    Operation,
    Run,
    Policy,
    Analysis,
    Invitation,
    Grant,
    DiscussionTurn,
    Review,
    Notification,
    AttentionItem,
    Member,
    ApprovalGroup,
    Session,
    SignupInvitation,
    TimelineEvent,
    Artifact,
    Mount,
    SupportAccess,
    DeviceRecord,
    Delegation,
    Recovery,
    OwnerTransition,
    Billing,
    Evidence,
    CheckAcknowledgement,
    ProviderConnection,
    RemoteLink,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum CollaborationMention {
    Spool {
        spool: Uuid,
    },
    Thread {
        spool: Uuid,
        thread: ContentHash,
    },
    State {
        spool: Uuid,
        state: StateId,
    },
    GitCommit {
        spool: Uuid,
        oid: String,
    },
    Checkout {
        spool: Uuid,
        device: [u8; 32],
        id: String,
    },
    Record {
        spool: Option<Uuid>,
        record_kind: CollaborationRecordKind,
        id: String,
    },
    Device {
        key: [u8; 32],
    },
}
impl CollaborationMetadata {
    pub(crate) fn validate(&self) -> Result<(), CollaborationCodecError> {
        if self.scope.spool.is_nil()
            || self.actor.principal_id.is_nil()
            || self.mentions.len() > 128
        {
            return Err(invalid(
                "collaboration requires non-nil scope/actor and at most 128 mentions",
            ));
        }
        if self.actor.agent_id.as_ref().is_some_and(|id| {
            id.trim().is_empty() || id.len() > 512 || id.chars().any(char::is_control)
        }) {
            return Err(invalid("invalid stable collaboration agent identity"));
        }
        for mention in &self.mentions {
            mention.validate()?;
        }
        Ok(())
    }
}
impl CollaborationMention {
    pub fn validate(&self) -> Result<(), CollaborationCodecError> {
        match self {
            CollaborationMention::Spool { spool }
            | CollaborationMention::Thread { spool, .. }
            | CollaborationMention::State { spool, .. } => {
                if spool.is_nil() {
                    return Err(invalid("mention spool cannot be nil"));
                }
            }
            CollaborationMention::Record { spool, id, .. } => {
                if spool.is_some_and(|id| id.is_nil())
                    || id.trim().is_empty()
                    || id.len() > 1024
                    || id.chars().any(char::is_control)
                {
                    return Err(invalid("invalid stable mention identity"));
                }
            }
            CollaborationMention::Checkout { spool, id, .. } => {
                if spool.is_nil()
                    || id.trim().is_empty()
                    || id.len() > 1024
                    || id.chars().any(char::is_control)
                {
                    return Err(invalid("invalid stable mention identity"));
                }
            }
            CollaborationMention::GitCommit { spool, oid } => {
                if spool.is_nil()
                    || !matches!(oid.len(), 40 | 64)
                    || !oid
                        .bytes()
                        .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
                {
                    return Err(invalid(
                        "Git mention requires exact lower-case commit identity",
                    ));
                }
            }
            CollaborationMention::Device { .. } => {}
        }
        Ok(())
    }
}

fn invalid(message: &str) -> CollaborationCodecError {
    CollaborationCodecError::Invalid(message.into())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::object::{
        Attribution, CollabOpId, CollaborationIdempotencyKey, CollaborationOperationBodyV1,
        CollaborationOperationEnvelope, DiscussionRecordId, DiscussionTurnV1, Principal,
    };
    fn operation() -> CollaborationOperationEnvelope {
        CollaborationOperationEnvelope::new(
            DiscussionRecordId::generate(),
            vec![CollabOpId::from_bytes([9; 32])],
            CollaborationIdempotencyKey::new("append-1").expect("operation identity"),
            Attribution::human(Principal::new("display name", "")),
            1,
            CollaborationOperationBodyV1::AppendTurn {
                turn: DiscussionTurnV1::new("See the exact reviewed state").expect("turn"),
            },
        )
        .expect("operation")
        .with_metadata(CollaborationMetadata {
            scope: CollaborationScope {
                spool: Uuid::from_u128(1),
                thread: Some(ContentHash::from_bytes([3; 32])),
            },
            actor: CollaborationActor {
                principal_id: Uuid::from_u128(2),
                agent_id: Some("agent-device-7".into()),
            },
            mentions: vec![CollaborationMention::State {
                spool: Uuid::from_u128(1),
                state: StateId::from_bytes([4; 32]),
            }],
        })
        .expect("portable actor and mention")
    }
    #[test]
    fn signed_record_identity_retains_and_binds_stable_actor_scope_and_mentions() {
        let original = operation();
        let bytes = original.encode().expect("canonical operation");
        let decoded = CollaborationOperationEnvelope::decode(&bytes).expect("portable decode");
        assert_eq!(
            decoded.operation.metadata, original.metadata,
            "actor and references must survive synchronization"
        );
        assert_eq!(decoded.operation, original);
        assert_eq!(
            decoded.operation.id().expect("id reuses decoded bytes"),
            decoded.operation_id
        );
        assert_eq!(
            decoded.operation.id().expect("cached id"),
            CollabOpId::for_bytes(&decoded.operation.encode().expect("re-encode"))
        );
        let mut changed = original.clone();
        changed.metadata.as_mut().expect("metadata").actor.agent_id = Some("another-agent".into());
        let changed_id = changed.id().expect("changed actor");
        assert_ne!(
            decoded.operation_id, changed_id,
            "author binding must change when agent changes"
        );
        assert_eq!(
            changed_id,
            CollabOpId::for_bytes(&changed.encode().expect("changed actor"))
        );
        changed = original.clone();
        changed
            .metadata
            .as_mut()
            .expect("metadata")
            .mentions
            .clear();
        let changed_id = changed.id().expect("removed mention");
        assert_ne!(
            decoded.operation_id, changed_id,
            "mentions belong to the author-signed identity"
        );
        assert_eq!(
            changed_id,
            CollabOpId::for_bytes(&changed.encode().expect("removed mention"))
        );
        changed = original;
        changed.metadata.as_mut().expect("metadata").scope.spool = Uuid::nil();
        assert!(
            changed.encode().is_err(),
            "invalid scope cannot become a canonical operation"
        );
    }
}