Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Implement action authorization
cloudhead committed 2 years ago
commit c903a958e7600f4758997d82a0e2d5017a82df57
parent f760b1153feb8b9e96493f5ac8dc1efdd7ba580d
10 files changed +641 -262
modified radicle-cli/examples/workflow/3-issues.md
@@ -26,51 +26,11 @@ $ rad issue list
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
```

-
Great! Now we've documented the issue for ourselves and others.
-

-
Just like with other project management systems, the issue can be assigned to
-
others to work on.  This is to ensure work is not duplicated.
-

-
Let's assign this issue to ourself.
-

-
```
-
$ rad assign 2f6eb49efac492327f71437b6bfc01b49afa0981 --to did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
```
-

-
It will now show in the list of issues assigned to us.
-

-
```
-
$ rad issue list --assigned
-
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
-
│ ●   ID        Title                         Author           Labels   Assignees   Opened       │
-
├────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   2f6eb49   flux capacitor underpowered   bob      (you)            bob         [    ..    ] │
-
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
-
```
-

-
In addition, you can see that when you run `rad issue show` you are listed under the `Assignees`.
-

-
```
-
$ rad issue show 2f6eb49
-
╭─────────────────────────────────────────────────────────╮
-
│ Title      flux capacitor underpowered                  │
-
│ Issue      2f6eb49efac492327f71437b6bfc01b49afa0981     │
-
│ Author     bob (you)                                    │
-
│ Assignees  z6Mkt67…v4N1tRk                              │
-
│ Status     open                                         │
-
│                                                         │
-
│ Flux capacitor power requirements exceed current supply │
-
╰─────────────────────────────────────────────────────────╯
-
```
-

-
Note: this can always be undone with the `unassign` subcommand.
-

-
Great, now we have communicated to the world about our car's defect.
-

-
But wait! We've found an important detail about the car's power requirements.
-
It will help whoever works on a fix.
+
Great! Now we've documented the issue for ourselves and others. But wait, we've
+
found an important detail about the car's power requirements. It will help
+
whoever works on a fix.

```
$ rad comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts'
-
24ab347afda760e77d565f9cb013c6db560f44fd
+
d2b50873009b93680698aef4f57f43f7e850b651
```
modified radicle-cob/src/history.rs
@@ -60,12 +60,11 @@ impl History {
    /// accumulator value of type `A`. However, unlike `fold` the function `f`
    /// may prune branches from the dependency graph by returning
    /// `ControlFlow::Break`.
-
    pub fn traverse<F, A>(&self, init: A, mut f: F) -> A
+
    pub fn traverse<F, A>(&self, init: A, roots: &[EntryId], mut f: F) -> A
    where
        F: for<'r> FnMut(A, &'r EntryId, &'r Entry) -> ControlFlow<A, A>,
    {
-
        self.graph
-
            .fold(&[self.root], init, |acc, k, v| f(acc, k, v))
+
        self.graph.fold(roots, init, |acc, k, v| f(acc, k, v))
    }

    /// Return a topologically-sorted list of history entries.
@@ -114,4 +113,12 @@ impl History {
            .get(&self.root)
            .expect("History::root: the root entry must be present in the graph")
    }
+

+
    /// Get the children of the given entry.
+
    pub fn children_of(&self, id: &EntryId) -> Vec<EntryId> {
+
        self.graph
+
            .get(id)
+
            .map(|n| n.dependents.iter().cloned().collect())
+
            .unwrap_or_default()
+
    }
}
modified radicle-cob/src/tests.rs
@@ -211,21 +211,26 @@ fn traverse_cobs() {
    )
    .unwrap();

+
    let root = object.history.root().id;
    // traverse over the history and filter by changes that were only authorized by terry
-
    let contents = object.history().traverse(Vec::new(), |mut acc, _, entry| {
-
        if entry.author() == terry_signer.public_key() {
-
            acc.push(entry.contents().head.clone());
-
        }
-
        ControlFlow::Continue(acc)
-
    });
+
    let contents = object
+
        .history()
+
        .traverse(Vec::new(), &[root], |mut acc, _, entry| {
+
            if entry.author() == terry_signer.public_key() {
+
                acc.push(entry.contents().head.clone());
+
            }
+
            ControlFlow::Continue(acc)
+
        });

    assert_eq!(contents, vec![b"issue 1".to_vec()]);

    // traverse over the history and filter by changes that were only authorized by neil
-
    let contents = object.history().traverse(Vec::new(), |mut acc, _, entry| {
-
        acc.push(entry.contents().head.clone());
-
        ControlFlow::Continue(acc)
-
    });
+
    let contents = object
+
        .history()
+
        .traverse(Vec::new(), &[root], |mut acc, _, entry| {
+
            acc.push(entry.contents().head.clone());
+
            ControlFlow::Continue(acc)
+
        });

    assert_eq!(contents, vec![b"issue 1".to_vec(), b"issue 2".to_vec()]);
}
modified radicle/src/cob/common.rs
@@ -24,6 +24,10 @@ impl Author {
    pub fn id(&self) -> &Did {
        &self.id
    }
+

+
    pub fn public_key(&self) -> &PublicKey {
+
        self.id.as_key()
+
    }
}

impl From<PublicKey> for Author {
@@ -278,6 +282,27 @@ impl std::str::FromStr for Uri {
    }
}

+
/// The result of an authorization check on an COB action.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+
pub enum Authorization {
+
    /// Action is allowed.
+
    Allow,
+
    /// Action is denied.
+
    Deny,
+
    /// Authorization cannot be determined due to missing object, eg. due to redaction.
+
    Unknown,
+
}
+

+
impl From<bool> for Authorization {
+
    fn from(value: bool) -> Self {
+
        if value {
+
            Self::Allow
+
        } else {
+
            Self::Deny
+
        }
+
    }
+
}
+

#[cfg(test)]
mod test {
    use super::*;
modified radicle/src/cob/identity.rs
@@ -323,11 +323,10 @@ impl store::FromHistory for Proposal {
        &TYPENAME
    }

-
    fn validate(&self) -> Result<(), Self::Error> {
-
        if self.revisions.is_empty() {
-
            return Err(ApplyError::Validate("no revisions found"));
-
        }
-
        Ok(())
+
    fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
        let mut identity = Self::default();
+
        identity.apply(op, repo)?;
+
        Ok(identity)
    }

    fn apply<R: ReadRepository>(&mut self, op: Op, _repo: &R) -> Result<(), Self::Error> {
modified radicle/src/cob/issue.rs
@@ -7,15 +7,16 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Label, Reaction, Timestamp, Uri};
+
use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp, Uri};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
-
use crate::cob::thread::{CommentId, Thread};
+
use crate::cob::thread::{Comment, CommentId, Thread};
use crate::cob::{store, ActorId, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
use crate::git;
-
use crate::prelude::{Did, ReadRepository};
+
use crate::identity::doc::{Doc, DocError};
+
use crate::prelude::{Did, ReadRepository, Verified};
use crate::storage::WriteRepository;

/// Issue operation.
@@ -31,16 +32,23 @@ pub type IssueId = ObjectId;
/// Error updating or creating issues.
#[derive(Error, Debug)]
pub enum Error {
-
    #[error("apply failed")]
-
    Apply,
-
    #[error("validation failed: {0}")]
-
    Validate(&'static str),
+
    /// Error loading the identity document.
+
    #[error("identity doc failed to load: {0}")]
+
    Doc(#[from] DocError),
    #[error("description missing")]
    DescriptionMissing,
    #[error("thread apply failed: {0}")]
    Thread(#[from] thread::Error),
    #[error("store: {0}")]
    Store(#[from] store::Error),
+
    #[error("history: {0}")]
+
    History(Box<dyn std::error::Error + Sync + Send + 'static>),
+
    /// Action not authorized.
+
    #[error("{0} not authorized to apply {1:?}")]
+
    NotAuthorized(ActorId, Action),
+
    /// General error initializing an issue.
+
    #[error("initialization failed: {0}")]
+
    Init(&'static str),
}

/// Reason why an issue was closed.
@@ -91,7 +99,7 @@ impl State {
}

/// Issue state. Accumulates [`Action`].
-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
    /// Actors assigned to this issue.
    pub(super) assignees: BTreeSet<Did>,
@@ -113,25 +121,52 @@ impl store::FromHistory for Issue {
        &TYPENAME
    }

-
    fn validate(&self) -> Result<(), Self::Error> {
-
        if self.title.is_empty() {
-
            return Err(Error::Validate("title is empty"));
-
        }
-
        if self.thread.validate().is_err() {
-
            return Err(Error::Validate("invalid thread"));
+
    fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
        let mut actions = op.actions.into_iter();
+
        let Some(Action::Comment { body, reply_to: None, embeds }) = actions.next() else {
+
            return Err(Error::Init("the first action must be of type `comment`"));
+
        };
+
        let comment = Comment::new(op.author, body, None, None, embeds, op.timestamp);
+
        let thread = Thread::new(op.id, comment);
+
        let mut issue = Issue::new(thread);
+

+
        for action in actions {
+
            issue.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
        }
-
        Ok(())
+
        Ok(issue)
    }

    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
+
        let doc = repo.identity_doc_at(op.identity)?.verified()?;
        for action in op.actions {
-
            self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
+
            match self.authorization(&action, &op.author, &doc) {
+
                Authorization::Allow => {
+
                    self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
+
                }
+
                Authorization::Deny => {
+
                    return Err(Error::NotAuthorized(op.author, action));
+
                }
+
                Authorization::Unknown => {
+
                    continue;
+
                }
+
            }
        }
        Ok(())
    }
}

impl Issue {
+
    /// Construct a new issue.
+
    pub fn new(thread: Thread) -> Self {
+
        Self {
+
            assignees: BTreeSet::default(),
+
            title: String::default(),
+
            state: State::default(),
+
            labels: BTreeSet::default(),
+
            thread,
+
        }
+
    }
+

    pub fn assigned(&self) -> impl Iterator<Item = &Did> + '_ {
        self.assignees.iter()
    }
@@ -180,6 +215,46 @@ impl Issue {
    pub fn comments(&self) -> impl Iterator<Item = (&CommentId, &thread::Comment)> {
        self.thread.comments()
    }
+

+
    /// Apply authorization rules on issue actions.
+
    pub fn authorization(
+
        &self,
+
        action: &Action,
+
        actor: &ActorId,
+
        doc: &Doc<Verified>,
+
    ) -> Authorization {
+
        if doc.is_delegate(actor) {
+
            // A delegate is authorized to do all actions.
+
            return Authorization::Allow;
+
        }
+
        let author: ActorId = *self.author().id().as_key();
+

+
        match action {
+
            // Only delegate can assign someone to an issue.
+
            Action::Assign { .. } => Authorization::Deny,
+
            // Issue authors can edit their own issues.
+
            Action::Edit { .. } => Authorization::from(*actor == author),
+
            // Issue authors can close or re-open their own issue.
+
            Action::Lifecycle { state } => Authorization::from(match state {
+
                State::Closed { .. } => *actor == author,
+
                State::Open => *actor == author,
+
            }),
+
            // Only delegate can label an issue.
+
            Action::Label { .. } => Authorization::Deny,
+
            // All roles can comment on an issues
+
            Action::Comment { .. } => Authorization::Allow,
+
            // All roles can edit or redact their own comments.
+
            Action::CommentEdit { id, .. } | Action::CommentRedact { id, .. } => {
+
                if let Some(comment) = self.comment(id) {
+
                    Authorization::from(*actor == comment.author())
+
                } else {
+
                    Authorization::Unknown
+
                }
+
            }
+
            // All roles can react to a comment on an issue.
+
            Action::CommentReact { .. } => Authorization::Allow,
+
        }
+
    }
}

impl Issue {
modified radicle/src/cob/patch.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::cob;
-
use crate::cob::common::{Author, Label, Reaction, Timestamp};
+
use crate::cob::common::{Author, Authorization, Label, Reaction, Timestamp};
use crate::cob::store::Transaction;
use crate::cob::store::{FromHistory as _, HistoryAction};
use crate::cob::thread;
@@ -105,18 +105,20 @@ pub enum Error {
    /// Error loading the document payload.
    #[error("payload failed to load: {0}")]
    Payload(#[from] PayloadError),
-
    /// The merge operation is invalid.
-
    #[error("invalid merge operation in {0}")]
-
    InvalidMerge(EntryId),
    /// Git error.
    #[error("git: {0}")]
    Git(#[from] git::ext::Error),
-
    /// Validation error.
-
    #[error("validation failed: {0}")]
-
    Validate(&'static str),
    /// Store error.
    #[error("store: {0}")]
    Store(#[from] store::Error),
+
    #[error("history error: {0}")]
+
    History(Box<dyn std::error::Error + Sync + Send + 'static>),
+
    /// Action not authorized by the author
+
    #[error("{0} not authorized to apply {1:?}")]
+
    NotAuthorized(ActorId, Action),
+
    /// Initialization failed.
+
    #[error("initialization failed: {0}")]
+
    Init(&'static str),
}

/// Patch operation.
@@ -341,7 +343,7 @@ impl MergeTarget {
}

/// Patch state.
-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
    /// Title of the patch.
    pub(super) title: String,
@@ -374,6 +376,24 @@ pub struct Patch {
}

impl Patch {
+
    /// Create a valid patch
+
    pub fn new(
+
        title: String,
+
        target: MergeTarget,
+
        (revision_id, revision): (RevisionId, Revision),
+
    ) -> Self {
+
        Self {
+
            title,
+
            state: State::default(),
+
            target,
+
            labels: BTreeSet::default(),
+
            merges: BTreeMap::default(),
+
            revisions: BTreeMap::from([(revision_id, Some(revision))]),
+
            assignees: BTreeSet::default(),
+
            timeline: vec![revision_id.into_inner()],
+
            reviews: BTreeMap::default(),
+
        }
+
    }
    /// Title of the patch.
    pub fn title(&self) -> &str {
        self.title.as_str()
@@ -523,6 +543,118 @@ impl Patch {
    pub fn is_draft(&self) -> bool {
        matches!(self.state(), State::Draft)
    }
+

+
    /// Apply authorization rules on patch actions.
+
    pub fn authorization(
+
        &self,
+
        action: &Action,
+
        actor: &ActorId,
+
        doc: &Doc<Verified>,
+
    ) -> Result<Authorization, Error> {
+
        if doc.is_delegate(actor) {
+
            // A delegate is authorized to do all actions.
+
            return Ok(Authorization::Allow);
+
        }
+
        let author = self.author().id().as_key();
+
        let outcome = match action {
+
            // The patch author can edit the patch and change its state.
+
            Action::Edit { .. } => Authorization::from(actor == author),
+
            Action::Lifecycle { state } => Authorization::from(match state {
+
                Lifecycle::Open { .. } => actor == author,
+
                Lifecycle::Draft { .. } => actor == author,
+
                Lifecycle::Archived { .. } => actor == author,
+
            }),
+
            // Only delegates can carry out these actions.
+
            Action::Label { .. } => Authorization::Deny,
+
            Action::Assign { .. } => Authorization::Deny,
+
            Action::Merge { .. } => match self.target() {
+
                MergeTarget::Delegates => Authorization::Deny,
+
            },
+
            // Anyone can submit a review.
+
            Action::Review { .. } => Authorization::Allow,
+
            // The review author can edit a review.
+
            Action::ReviewEdit { review, .. } => {
+
                if let Some((_, review)) = lookup::review(self, review)? {
+
                    Authorization::from(actor == review.author.public_key())
+
                } else {
+
                    // Redacted.
+
                    Authorization::Unknown
+
                }
+
            }
+
            Action::ReviewRedact { review, .. } => {
+
                if let Some((_, review)) = lookup::review(self, review)? {
+
                    Authorization::from(actor == review.author.public_key())
+
                } else {
+
                    // Redacted.
+
                    Authorization::Unknown
+
                }
+
            }
+
            // Anyone can comment on a review.
+
            Action::ReviewComment { .. } => Authorization::Allow,
+
            // The comment author can edit and redact their own comment.
+
            Action::ReviewCommentEdit {
+
                review, comment, ..
+
            }
+
            | Action::ReviewCommentRedact { review, comment } => {
+
                if let Some((_, review)) = lookup::review(self, review)? {
+
                    if let Some(comment) = review.comments.comment(comment) {
+
                        return Ok(Authorization::from(*actor == comment.author()));
+
                    }
+
                }
+
                // Redacted.
+
                Authorization::Unknown
+
            }
+
            // Anyone can react to a review comment.
+
            Action::ReviewCommentReact { .. } => Authorization::Allow,
+
            // The reviewer, commenter or revision author can resolve and unresolve review comments.
+
            Action::ReviewCommentResolve { review, comment }
+
            | Action::ReviewCommentUnresolve { review, comment } => {
+
                if let Some((revision, review)) = lookup::review(self, review)? {
+
                    if let Some(comment) = review.comments.comment(comment) {
+
                        return Ok(Authorization::from(
+
                            actor == &comment.author()
+
                                || actor == review.author.public_key()
+
                                || actor == revision.author.public_key(),
+
                        ));
+
                    }
+
                }
+
                // Redacted.
+
                Authorization::Unknown
+
            }
+
            // Only patch authors can propose revisions.
+
            Action::Revision { .. } => Authorization::from(actor == author),
+
            // Only the revision author can edit or redact their revision.
+
            Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
+
                if let Some(revision) = lookup::revision(self, revision)? {
+
                    Authorization::from(actor == revision.author.public_key())
+
                } else {
+
                    // Redacted.
+
                    Authorization::Unknown
+
                }
+
            }
+
            // Anyone can react to or comment on a revision.
+
            Action::RevisionReact { .. } => Authorization::Allow,
+
            Action::RevisionComment { .. } => Authorization::Allow,
+
            // Only the comment author can edit or redact their comment.
+
            Action::RevisionCommentEdit {
+
                revision, comment, ..
+
            }
+
            | Action::RevisionCommentRedact {
+
                revision, comment, ..
+
            } => {
+
                if let Some(revision) = lookup::revision(self, revision)? {
+
                    if let Some(comment) = revision.discussion.comment(comment) {
+
                        return Ok(Authorization::from(actor == &comment.author()));
+
                    }
+
                }
+
                // Redacted.
+
                Authorization::Unknown
+
            }
+
            // Anyone can react to a revision.
+
            Action::RevisionCommentReact { .. } => Authorization::Allow,
+
        };
+
        Ok(outcome)
+
    }
}

impl Patch {
@@ -533,7 +665,7 @@ impl Patch {
        entry: EntryId,
        author: ActorId,
        timestamp: Timestamp,
-
        identity: git::Oid,
+
        identity: &Doc<Verified>,
        repo: &R,
    ) -> Result<(), Error> {
        match action {
@@ -576,19 +708,18 @@ impl Patch {
                summary,
                verdict,
            } => {
-
                let Some(Some((revision, author))) =
-
                        self.reviews.get(&review) else {
-
                            return Err(Error::Missing(review.into_inner()));
-
                    };
+
                let Some(Some((revision, author))) = self.reviews.get(&review) else {
+
                    return Err(Error::Missing(review.into_inner()));
+
                };
                let Some(rev) = self.revisions.get_mut(revision) else {
-
                        return Err(Error::Missing(revision.into_inner()));
-
                    };
+
                    return Err(Error::Missing(revision.into_inner()));
+
                };
                // If the revision was redacted concurrently, there's nothing to do.
                // Likewise, if the review was redacted concurrently, there's nothing to do.
                if let Some(rev) = rev {
                    let Some(review) = rev.reviews.get_mut(author) else {
-
                            return Err(Error::Missing(review.into_inner()));
-
                        };
+
                        return Err(Error::Missing(review.into_inner()));
+
                    };
                    if let Some(review) = review {
                        review.summary = summary;
                        review.verdict = verdict;
@@ -621,7 +752,7 @@ impl Patch {
                active,
                location,
            } => {
-
                if let Some(revision) = lookup::revision(self, &revision)? {
+
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    let key = (author, reaction);
                    let reactions = revision.reactions.entry(location).or_default();

@@ -652,14 +783,20 @@ impl Patch {
                labels,
            } => {
                let Some(rev) = self.revisions.get_mut(&revision) else {
-
                        return Err(Error::Missing(revision.into_inner()));
-
                    };
+
                    return Err(Error::Missing(revision.into_inner()));
+
                };
                if let Some(rev) = rev {
                    // Nb. Applying two reviews by the same author is not allowed and
                    // results in the review being redacted.
                    rev.reviews.insert(
                        author,
-
                        Some(Review::new(verdict, summary.to_owned(), labels, timestamp)),
+
                        Some(Review::new(
+
                            Author::new(author),
+
                            verdict,
+
                            summary.to_owned(),
+
                            labels,
+
                            timestamp,
+
                        )),
                    );
                    // Update reviews index.
                    self.reviews
@@ -672,7 +809,7 @@ impl Patch {
                reaction,
                active,
            } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::react(
                        &mut review.comments,
                        entry,
@@ -684,7 +821,7 @@ impl Patch {
                }
            }
            Action::ReviewCommentRedact { review, comment } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::redact(&mut review.comments, entry, comment)?;
                }
            }
@@ -693,7 +830,7 @@ impl Patch {
                comment,
                body,
            } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::edit(
                        &mut review.comments,
                        entry,
@@ -705,12 +842,12 @@ impl Patch {
                }
            }
            Action::ReviewCommentResolve { review, comment } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::resolve(&mut review.comments, entry, comment)?;
                }
            }
            Action::ReviewCommentUnresolve { review, comment } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::unresolve(&mut review.comments, entry, comment)?;
                }
            }
@@ -720,7 +857,7 @@ impl Patch {
                location,
                reply_to,
            } => {
-
                if let Some(review) = lookup::review(self, &review)? {
+
                if let Some(review) = lookup::review_mut(self, &review)? {
                    thread::comment(
                        &mut review.comments,
                        entry,
@@ -736,41 +873,36 @@ impl Patch {
            Action::ReviewRedact { review } => {
                // Redactions must have observed a review to be valid.
                let Some(locator) = self.reviews.get_mut(&review) else {
-
                        return Err(Error::Missing(review.into_inner()));
-
                    };
+
                    return Err(Error::Missing(review.into_inner()));
+
                };
                // If the review is already redacted, do nothing.
                let Some((revision, reviewer)) = locator else {
-
                        return Ok(());
-
                    };
+
                    return Ok(());
+
                };
                // The revision must have existed at some point.
                let Some(redactable) = self.revisions.get_mut(revision) else {
-
                        return Err(Error::Missing(revision.into_inner()));
-
                    };
+
                    return Err(Error::Missing(revision.into_inner()));
+
                };
                // But it could be redacted.
                let Some(revision) = redactable else {
-
                        return Ok(());
-
                    };
+
                    return Ok(());
+
                };
                // The review must have existed as well.
                let Some(review) = revision.reviews.get_mut(reviewer) else {
-
                        return Err(Error::Missing(review.into_inner()));
-
                    };
+
                    return Err(Error::Missing(review.into_inner()));
+
                };
                // Set both the review and the locator in the review index to redacted.
                *review = None;
                *locator = None;
            }
            Action::Merge { revision, commit } => {
                // If the revision was redacted before the merge, ignore the merge.
-
                if lookup::revision(self, &revision)?.is_none() {
+
                if lookup::revision_mut(self, &revision)?.is_none() {
                    return Ok(());
                };
-
                let doc = repo.identity_doc_at(identity)?.verified()?;
-

                match self.target() {
                    MergeTarget::Delegates => {
-
                        if !doc.is_delegate(&author) {
-
                            return Err(Error::InvalidMerge(entry));
-
                        }
-
                        let proj = doc.project()?;
+
                        let proj = identity.project()?;
                        let branch = git::refs::branch(proj.default_branch());

                        // Nb. We don't return an error in case the merge commit is not an
@@ -804,7 +936,7 @@ impl Patch {
                    },
                );
                // Discard revisions that weren't merged by a threshold of delegates.
-
                merges.retain(|_, count| *count >= doc.threshold);
+
                merges.retain(|_, count| *count >= identity.threshold);

                match merges.into_keys().collect::<Vec<_>>().as_slice() {
                    [] => {
@@ -832,7 +964,7 @@ impl Patch {
                reply_to,
                ..
            } => {
-
                if let Some(revision) = lookup::revision(self, &revision)? {
+
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    thread::comment(
                        &mut revision.discussion,
                        entry,
@@ -850,7 +982,7 @@ impl Patch {
                comment,
                body,
            } => {
-
                if let Some(revision) = lookup::revision(self, &revision)? {
+
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    thread::edit(
                        &mut revision.discussion,
                        entry,
@@ -862,7 +994,7 @@ impl Patch {
                }
            }
            Action::RevisionCommentRedact { revision, comment } => {
-
                if let Some(revision) = lookup::revision(self, &revision)? {
+
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    thread::redact(&mut revision.discussion, entry, comment)?;
                }
            }
@@ -872,7 +1004,7 @@ impl Patch {
                reaction,
                active,
            } => {
-
                if let Some(revision) = lookup::revision(self, &revision)? {
+
                if let Some(revision) = lookup::revision_mut(self, &revision)? {
                    thread::react(
                        &mut revision.discussion,
                        entry,
@@ -896,22 +1028,53 @@ impl store::FromHistory for Patch {
        &TYPENAME
    }

-
    fn validate(&self) -> Result<(), Self::Error> {
-
        if self.revisions.is_empty() {
-
            return Err(Error::Validate("no revisions found"));
-
        }
-
        if self.title().is_empty() {
-
            return Err(Error::Validate("empty title"));
+
    fn init<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
        let mut actions = op.actions.into_iter();
+
        let Some(Action::Revision { description, base, oid, resolves }) = actions.next() else {
+
            return Err(Error::Init("the first action must be of type `revision`"));
+
        };
+
        let Some(Action::Edit { title, target }) = actions.next() else {
+
            return Err(Error::Init("the second action must be of type `edit`"));
+
        };
+
        let revision = Revision::new(
+
            op.author.into(),
+
            description,
+
            base,
+
            oid,
+
            op.timestamp,
+
            resolves,
+
        );
+
        let mut patch = Patch::new(title, target, (RevisionId(op.id), revision));
+
        let doc = repo.identity_doc_at(op.identity)?.verified()?;
+

+
        for action in actions {
+
            patch.action(action, op.id, op.author, op.timestamp, &doc, repo)?;
        }
-
        Ok(())
+
        Ok(patch)
    }

    fn apply<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
        debug_assert!(!self.timeline.contains(&op.id));
        self.timeline.push(op.id);

+
        let doc = repo.identity_doc_at(op.identity)?.verified()?;
+

        for action in op.actions {
-
            self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
+
            match self.authorization(&action, &op.author, &doc)? {
+
                Authorization::Allow => {
+
                    self.action(action, op.id, op.author, op.timestamp, &doc, repo)?;
+
                }
+
                Authorization::Deny => {
+
                    return Err(Error::NotAuthorized(op.author, action));
+
                }
+
                Authorization::Unknown => {
+
                    // In this case, since there is not enough information to determine
+
                    // whether the action is authorized or not, we simply ignore it.
+
                    // It's likely that the target object was redacted, and we can't
+
                    // verify whether the action would have been allowed or not.
+
                    continue;
+
                }
+
            }
        }
        Ok(())
    }
@@ -921,6 +1084,19 @@ mod lookup {
    use super::*;

    pub fn revision<'a>(
+
        patch: &'a Patch,
+
        revision: &RevisionId,
+
    ) -> Result<Option<&'a Revision>, Error> {
+
        match patch.revisions.get(revision) {
+
            Some(Some(revision)) => Ok(Some(revision)),
+
            // Redacted.
+
            Some(None) => Ok(None),
+
            // Missing. Causal error.
+
            None => Err(Error::Missing(revision.into_inner())),
+
        }
+
    }
+

+
    pub fn revision_mut<'a>(
        patch: &'a mut Patch,
        revision: &RevisionId,
    ) -> Result<Option<&'a mut Revision>, Error> {
@@ -934,6 +1110,35 @@ mod lookup {
    }

    pub fn review<'a>(
+
        patch: &'a Patch,
+
        review: &ReviewId,
+
    ) -> Result<Option<(&'a Revision, &'a Review)>, Error> {
+
        match patch.reviews.get(review) {
+
            Some(Some((revision, author))) => {
+
                match patch.revisions.get(revision) {
+
                    Some(Some(r)) => {
+
                        let Some(review) = r.reviews.get(author) else {
+
                            return Err(Error::Missing(review.into_inner()));
+
                        };
+
                        Ok(review.as_ref().map(|review| (r, review)))
+
                    }
+
                    Some(None) => {
+
                        // If the revision was redacted concurrently, there's nothing to do.
+
                        // Likewise, if the review was redacted concurrently, there's nothing to do.
+
                        Ok(None)
+
                    }
+
                    None => Err(Error::Missing(revision.into_inner())),
+
                }
+
            }
+
            Some(None) => {
+
                // Redacted.
+
                Ok(None)
+
            }
+
            None => Err(Error::Missing(review.into_inner())),
+
        }
+
    }
+

+
    pub fn review_mut<'a>(
        patch: &'a mut Patch,
        review: &ReviewId,
    ) -> Result<Option<&'a mut Review>, Error> {
@@ -942,8 +1147,8 @@ mod lookup {
                match patch.revisions.get_mut(revision) {
                    Some(Some(r)) => {
                        let Some(review) = r.reviews.get_mut(author) else {
-
                        return Err(Error::Missing(review.into_inner()));
-
                    };
+
                            return Err(Error::Missing(review.into_inner()));
+
                        };
                        Ok(review.as_mut())
                    }
                    Some(None) => {
@@ -1180,8 +1385,10 @@ pub struct CodeLocation {
}

/// A patch review on a revision.
-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Review {
+
    /// Review author.
+
    pub(super) author: Author,
    /// Review verdict.
    ///
    /// The verdict cannot be changed, since revisions are immutable.
@@ -1218,12 +1425,14 @@ impl Serialize for Review {

impl Review {
    pub fn new(
+
        author: Author,
        verdict: Option<Verdict>,
        summary: Option<String>,
        labels: Vec<Label>,
        timestamp: Timestamp,
    ) -> Self {
        Self {
+
            author,
            verdict,
            summary,
            comments: Thread::default(),
@@ -2192,37 +2401,57 @@ mod test {
        let base = git::Oid::from_str("cb18e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap();
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
        let mut alice = Actor::new(MockSigner::default());
-
        let mut patch = Patch::default();
-
        let repo = gen::<MockRepository>(1);
+
        let rid = gen::<Id>(1);
+
        let doc = Doc::new(
+
            gen::<Project>(1),
+
            nonempty::NonEmpty::new(alice.did()),
+
            1,
+
            identity::Visibility::Public,
+
        )
+
        .verified()
+
        .unwrap();
+
        let repo = MockRepository::new(rid, doc);

-
        let a1 = alice.op::<Patch>(Action::Revision {
-
            description: String::new(),
+
        let a1 = alice.op::<Patch>([
+
            Action::Revision {
+
                description: String::new(),
+
                base,
+
                oid,
+
                resolves: Default::default(),
+
            },
+
            Action::Edit {
+
                title: String::from("My patch"),
+
                target: MergeTarget::Delegates,
+
            },
+
        ]);
+
        let a2 = alice.op::<Patch>([Action::Revision {
+
            description: String::from("Second revision"),
            base,
            oid,
            resolves: Default::default(),
-
        });
-
        let a2 = alice.op::<Patch>(Action::RevisionRedact {
-
            revision: RevisionId(a1.id()),
-
        });
-
        let a3 = alice.op::<Patch>(Action::Review {
-
            revision: RevisionId(a1.id()),
+
        }]);
+
        let a3 = alice.op::<Patch>([Action::RevisionRedact {
+
            revision: RevisionId(a2.id()),
+
        }]);
+
        let a4 = alice.op::<Patch>([Action::Review {
+
            revision: RevisionId(a2.id()),
            summary: None,
            verdict: Some(Verdict::Accept),
            labels: vec![],
-
        });
-
        let a4 = alice.op::<Patch>(Action::Merge {
-
            revision: RevisionId(a1.id()),
+
        }]);
+
        let a5 = alice.op::<Patch>([Action::Merge {
+
            revision: RevisionId(a2.id()),
            commit: oid,
-
        });
-

-
        patch.apply(a1, &repo).unwrap();
-
        assert!(patch.revisions().next().is_some());
+
        }]);

-
        patch.apply(a2, &repo).unwrap();
-
        assert!(patch.revisions().next().is_none());
+
        let mut patch = Patch::from_ops([a1, a2], &repo).unwrap();
+
        assert_eq!(patch.revisions().count(), 2);

        patch.apply(a3, &repo).unwrap();
+
        assert_eq!(patch.revisions().count(), 1);
+

        patch.apply(a4, &repo).unwrap();
+
        patch.apply(a5, &repo).unwrap();
    }

    #[test]
@@ -2234,22 +2463,32 @@ mod test {
        let alice = MockSigner::default();
        let bob = MockSigner::default();
        let mut h0: cob::test::HistoryBuilder<Patch> = cob::test::history(
-
            &Action::Revision {
-
                description: String::from("Original"),
-
                base,
-
                oid,
-
                resolves: Default::default(),
-
            },
+
            &[
+
                Action::Revision {
+
                    description: String::from("Original"),
+
                    base,
+
                    oid,
+
                    resolves: Default::default(),
+
                },
+
                Action::Edit {
+
                    title: String::from("Some patch"),
+
                    target: MergeTarget::Delegates,
+
                },
+
            ],
            time,
            &alice,
        );
        h0.commit(
-
            &Action::Edit {
-
                title: String::from("Some patch"),
-
                target: MergeTarget::Delegates,
+
            &Action::Revision {
+
                description: String::from("New"),
+
                base,
+
                oid,
+
                resolves: Default::default(),
            },
            &alice,
        );
+
        let patch = Patch::from_history(&h0, &repo).unwrap();
+
        assert_eq!(patch.revisions().count(), 2);

        let mut h1 = h0.clone();
        h1.commit(
@@ -2272,7 +2511,7 @@ mod test {
        h0.merge(h2);

        let patch = Patch::from_history(&h0, &repo).unwrap();
-
        assert_eq!(patch.revisions().count(), 0);
+
        assert_eq!(patch.revisions().count(), 1);
    }

    #[test]
@@ -2283,18 +2522,24 @@ mod test {
        let repo = gen::<MockRepository>(1);
        let reaction = Reaction::new('👍').expect("failed to create a reaction");

-
        let a1 = alice.op::<Patch>(Action::Revision {
-
            description: String::new(),
-
            base,
-
            oid,
-
            resolves: Default::default(),
-
        });
-
        let a2 = alice.op::<Patch>(Action::RevisionReact {
+
        let a1 = alice.op::<Patch>([
+
            Action::Revision {
+
                description: String::new(),
+
                base,
+
                oid,
+
                resolves: Default::default(),
+
            },
+
            Action::Edit {
+
                title: String::from("My patch"),
+
                target: MergeTarget::Delegates,
+
            },
+
        ]);
+
        let a2 = alice.op::<Patch>([Action::RevisionReact {
            revision: RevisionId(a1.id()),
            location: None,
            reaction,
            active: true,
-
        });
+
        }]);
        let patch = Patch::from_ops([a1, a2], &repo).unwrap();

        let (_, r1) = patch.revisions().next().unwrap();
modified radicle/src/cob/store.rs
@@ -1,9 +1,9 @@
//! Generic COB storage.
#![allow(clippy::large_enum_variant)]
#![allow(clippy::type_complexity)]
+
use std::fmt::Debug;
use std::marker::PhantomData;
use std::ops::ControlFlow;
-
use std::sync::Arc;

use nonempty::NonEmpty;
use serde::{Deserialize, Serialize};
@@ -19,7 +19,15 @@ use crate::storage::git as storage;
use crate::storage::SignRepository;
use crate::{cob, identity};

-
pub trait HistoryAction: std::fmt::Debug {
+
#[derive(Debug, thiserror::Error)]
+
pub enum HistoryError<T: FromHistory> {
+
    #[error("apply: {0}")]
+
    Apply(T::Error),
+
    #[error("operation decoding failed: {0}")]
+
    Op(#[from] cob::op::OpEncodingError),
+
}
+

+
pub trait HistoryAction: Debug {
    /// Parent objects this action depends on. For example, patch revisions
    /// have the commit objects as their parent.
    fn parents(&self) -> Vec<git::Oid> {
@@ -29,7 +37,7 @@ pub trait HistoryAction: std::fmt::Debug {

/// A type that can be materialized from an event history.
/// All collaborative objects implement this trait.
-
pub trait FromHistory: Sized + Default + PartialEq {
+
pub trait FromHistory: Sized + PartialEq + Debug {
    /// The underlying action composing each operation.
    type Action: HistoryAction + for<'de> Deserialize<'de> + Serialize;
    /// Error returned by `apply` function.
@@ -38,6 +46,9 @@ pub trait FromHistory: Sized + Default + PartialEq {
    /// The object type name.
    fn type_name() -> &'static TypeName;

+
    /// Initialize a collarorative object
+
    fn init<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error>;
+

    /// Apply a list of operations to the state.
    fn apply<R: ReadRepository>(
        &mut self,
@@ -45,21 +56,26 @@ pub trait FromHistory: Sized + Default + PartialEq {
        repo: &R,
    ) -> Result<(), Self::Error>;

-
    /// Validate the object. Returns an error if the object is invalid.
-
    fn validate(&self) -> Result<(), Self::Error>;
-

    /// Create an object from a history.
-
    fn from_history<R: ReadRepository>(history: &History, repo: &R) -> Result<Self, Self::Error> {
+
    fn from_history<R: ReadRepository>(
+
        history: &History,
+
        repo: &R,
+
    ) -> Result<Self, HistoryError<Self>> {
        self::from_history::<R, Self>(history, repo)
    }

+
    #[cfg(test)]
    /// Create an object from individual operations.
    /// Returns an error if any of the operations fails to apply.
    fn from_ops<R: ReadRepository>(
        ops: impl IntoIterator<Item = Op<Self::Action>>,
        repo: &R,
    ) -> Result<Self, Self::Error> {
-
        let mut state = Self::default();
+
        let mut ops = ops.into_iter();
+
        let Some(init) = ops.next() else {
+
            panic!("FromHistory::from_ops: operations list is empty");
+
        };
+
        let mut state = Self::init(init, repo)?;
        for op in ops {
            state.apply(op, repo)?;
        }
@@ -72,8 +88,12 @@ pub trait FromHistory: Sized + Default + PartialEq {
pub fn from_history<R: ReadRepository, T: FromHistory>(
    history: &History,
    repo: &R,
-
) -> Result<T, T::Error> {
-
    let obj = history.traverse(T::default(), |mut acc, _, entry| {
+
) -> Result<T, HistoryError<T>> {
+
    let root = history.root();
+
    let children = history.children_of(root.id());
+
    let op = Op::try_from(root)?;
+
    let initial = T::init(op, repo).map_err(HistoryError::Apply)?;
+
    let obj = history.traverse(initial, &children, |mut acc, _, entry| {
        match Op::try_from(entry) {
            Ok(op) => {
                if let Err(err) = acc.apply(op, repo) {
@@ -89,8 +109,6 @@ pub fn from_history<R: ReadRepository, T: FromHistory>(
        ControlFlow::Continue(acc)
    });

-
    obj.validate()?;
-

    Ok(obj)
}

@@ -111,8 +129,6 @@ pub enum Error {
    Serialize(#[from] serde_json::Error),
    #[error("object `{1}` of type `{0}` was not found")]
    NotFound(TypeName, ObjectId),
-
    #[error("apply: {0}")]
-
    Apply(Arc<dyn std::error::Error + Sync + Send + 'static>),
    #[error("signed refs: {0}")]
    SignRefs(#[from] storage::Error),
    #[error("failed to find reference '{name}': {err}")]
@@ -121,11 +137,13 @@ pub enum Error {
        #[source]
        err: git::Error,
    },
+
    #[error("history: {0}")]
+
    History(Box<dyn std::error::Error + Sync + Send + 'static>),
}

impl Error {
-
    fn apply(e: impl std::error::Error + Sync + Send + 'static) -> Self {
-
        Self::Apply(Arc::new(e))
+
    fn history(e: impl std::error::Error + Send + Sync + 'static) -> Self {
+
        Self::History(Box::new(e))
    }
}

@@ -158,7 +176,7 @@ impl<'a, T, R: ReadRepository> Store<'a, T, R> {
impl<'a, T, R> Store<'a, T, R>
where
    R: ReadRepository + SignRepository + cob::Store,
-
    T: FromHistory,
+
    T: FromHistory + 'static,
    T::Action: Serialize,
{
    /// Update an object.
@@ -218,7 +236,7 @@ where
                contents,
            },
        )?;
-
        let object = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
+
        let object = T::from_history(cob.history(), self.repo).map_err(Error::history)?;

        self.repo.sign_refs(signer).map_err(Error::SignRefs)?;

@@ -250,7 +268,7 @@ where
impl<'a, T, R> Store<'a, T, R>
where
    R: ReadRepository + cob::Store,
-
    T: FromHistory,
+
    T: FromHistory + 'static,
    T::Action: Serialize,
{
    /// Get an object.
@@ -258,7 +276,7 @@ where
        let cob = cob::get(self.repo, T::type_name(), id)?;

        if let Some(cob) = cob {
-
            let obj = T::from_history(cob.history(), self.repo).map_err(Error::apply)?;
+
            let obj = T::from_history(cob.history(), self.repo).map_err(Error::history)?;

            Ok(Some(obj))
        } else {
@@ -271,7 +289,7 @@ where
        let raw = cob::list(self.repo, T::type_name())?;

        Ok(raw.into_iter().map(|o| {
-
            let obj = T::from_history(o.history(), self.repo).map_err(Error::apply)?;
+
            let obj = T::from_history(o.history(), self.repo).map_err(Error::history)?;
            Ok((*o.id(), obj))
        }))
    }
@@ -297,7 +315,7 @@ pub struct Transaction<T: FromHistory> {
    embeds: Vec<Embed>,
}

-
impl<T: FromHistory> Transaction<T> {
+
impl<T: FromHistory + 'static> Transaction<T> {
    /// Create a new transaction.
    pub fn new(actor: ActorId) -> Self {
        Self {
@@ -395,10 +413,13 @@ pub fn ops<R: cob::Store>(
    let cob = cob::get(repo, type_name, id)?;

    if let Some(cob) = cob {
-
        let ops = cob.history().traverse(Vec::new(), |mut ops, _, entry| {
-
            ops.push(Op::from(entry.clone()));
-
            ControlFlow::Continue(ops)
-
        });
+
        let root = cob.history().root();
+
        let ops = cob
+
            .history()
+
            .traverse(Vec::new(), &[root.id], |mut ops, _, entry| {
+
                ops.push(Op::from(entry.clone()));
+
                ControlFlow::Continue(ops)
+
            });
        Ok(ops)
    } else {
        Err(Error::NotFound(type_name.clone(), *id))
modified radicle/src/cob/test.rs
@@ -57,18 +57,23 @@ impl<T: FromHistory> HistoryBuilder<T>
where
    T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
{
-
    pub fn new<G: Signer>(action: &T::Action, time: Timestamp, signer: &G) -> HistoryBuilder<T> {
+
    pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
        let resource = arbitrary::oid();
        let revision = arbitrary::oid();
-
        let (data, root) = encoded::<T, _>(action, time, [], signer);
+
        let (contents, oids): (Vec<Vec<u8>>, Vec<Oid>) = actions
+
            .iter()
+
            .map(|a| encoded::<T, _>(a, time, [], signer))
+
            .unzip();
+
        let contents = NonEmpty::from_vec(contents).unwrap();
+
        let root = oids.first().unwrap();
        let manifest = Manifest::new(T::type_name().clone(), Version::default());
-
        let signature = signer.sign(data.as_slice());
+
        let signature = signer.sign(&[0]);
        let signature = ExtendedSignature::new(*signer.public_key(), signature);
        let change = Entry {
-
            id: root,
+
            id: *root,
            signature,
            resource,
-
            contents: NonEmpty::new(data),
+
            contents,
            timestamp: time.as_secs(),
            revision,
            parents: vec![],
@@ -125,14 +130,14 @@ impl<A> Deref for HistoryBuilder<A> {

/// Create a new test history.
pub fn history<T: FromHistory, G: Signer>(
-
    action: &T::Action,
+
    actions: &[T::Action],
    time: Timestamp,
    signer: &G,
) -> HistoryBuilder<T>
where
    T::Action: Serialize + Eq + 'static,
{
-
    HistoryBuilder::new(action, time, signer)
+
    HistoryBuilder::new(actions, time, signer)
}

/// An object that can be used to create and sign operations.
@@ -156,22 +161,23 @@ impl<G: Signer> Actor<G> {
    /// Create a new operation.
    pub fn op_with<T: FromHistory>(
        &mut self,
-
        action: T::Action,
+
        actions: impl IntoIterator<Item = T::Action>,
        identity: Oid,
        timestamp: Timestamp,
    ) -> Op<T::Action>
    where
        T::Action: Clone + Serialize,
    {
+
        let actions = actions.into_iter().collect::<Vec<_>>();
        let data = encoding::encode(serde_json::json!({
-
            "action": action,
+
            "action": actions,
            "nonce": fastrand::u64(..),
        }))
        .unwrap();
        let oid = git::raw::Oid::hash_object(git::raw::ObjectType::Blob, &data).unwrap();
        let id = oid.into();
        let author = *self.signer.public_key();
-
        let actions = NonEmpty::new(action);
+
        let actions = NonEmpty::from_vec(actions).unwrap();
        let manifest = Manifest::new(T::type_name().clone(), Version::default());
        let parents = vec![];

@@ -187,14 +193,17 @@ impl<G: Signer> Actor<G> {
    }

    /// Create a new operation.
-
    pub fn op<T: FromHistory>(&mut self, action: T::Action) -> Op<T::Action>
+
    pub fn op<T: FromHistory>(
+
        &mut self,
+
        actions: impl IntoIterator<Item = T::Action>,
+
    ) -> Op<T::Action>
    where
        T::Action: Clone + Serialize,
    {
        let identity = arbitrary::oid();
        let timestamp = Timestamp::now();

-
        self.op_with::<T>(action, identity, timestamp)
+
        self.op_with::<T>(actions, identity, timestamp)
    }

    /// Get the actor's DID.
@@ -213,19 +222,19 @@ impl<G: Signer> Actor<G> {
        oid: git::Oid,
        repo: &R,
    ) -> Result<Patch, patch::Error> {
-
        Patch::from_ops(
-
            [
-
                self.op::<Patch>(patch::Action::Revision {
+
        Patch::init(
+
            self.op::<Patch>([
+
                patch::Action::Revision {
                    description: description.to_string(),
                    base,
                    oid,
                    resolves: Default::default(),
-
                }),
-
                self.op::<Patch>(patch::Action::Edit {
+
                },
+
                patch::Action::Edit {
                    title: title.to_string(),
                    target: patch::MergeTarget::default(),
-
                }),
-
            ],
+
                },
+
            ]),
            repo,
        )
    }
modified radicle/src/cob/thread.rs
@@ -9,6 +9,7 @@ use thiserror::Error;
use crate::cob;
use crate::cob::common::{Reaction, Timestamp, Uri};
use crate::cob::{ActorId, Embed, EntryId, Op};
+
use crate::git;
use crate::prelude::ReadRepository;

/// Type name of a thread, as well as the domain for all thread operations.
@@ -28,15 +29,15 @@ pub enum Error {
    /// that hasn't happened yet.
    #[error("causal dependency {0:?} missing")]
    Missing(EntryId),
-
    /// Validation error.
-
    #[error("validation failed: {0}")]
-
    Validate(&'static str),
    /// Error with comment operation.
    #[error("comment {0} is invalid")]
    Comment(EntryId),
    /// Error with edit operation.
    #[error("edit {0} is invalid")]
    Edit(EntryId),
+
    /// Object initialization failed.
+
    #[error("initialization failed: {0}")]
+
    Init(&'static str),
}

/// Identifies a comment.
@@ -258,7 +259,7 @@ impl<T> Thread<T> {
    pub fn new(id: CommentId, comment: T) -> Self {
        Self {
            comments: BTreeMap::from_iter([(id, Some(comment))]),
-
            timeline: Vec::default(),
+
            timeline: vec![id],
        }
    }

@@ -300,6 +301,39 @@ impl<T> Thread<T> {
    }
}

+
impl Thread {
+
    /// Apply a single action to the thread.
+
    fn action<R: ReadRepository>(
+
        &mut self,
+
        action: Action,
+
        entry: EntryId,
+
        author: ActorId,
+
        timestamp: Timestamp,
+
        _identity: git::Oid,
+
        _repo: &R,
+
    ) -> Result<(), Error> {
+
        match action {
+
            Action::Comment { body, reply_to } => {
+
                comment(self, entry, author, timestamp, body, reply_to, None, vec![])?;
+
            }
+
            Action::Edit { id, body } => {
+
                edit(self, entry, id, timestamp, body, vec![])?;
+
            }
+
            Action::Redact { id } => {
+
                redact(self, entry, id)?;
+
            }
+
            Action::React {
+
                to,
+
                reaction,
+
                active,
+
            } => {
+
                react(self, entry, author, to, reaction, active)?;
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

impl<L> Thread<Comment<L>> {
    pub fn replies<'a>(
        &'a self,
@@ -324,37 +358,36 @@ impl cob::store::FromHistory for Thread {
        &TYPENAME
    }

-
    fn validate(&self) -> Result<(), Self::Error> {
-
        if self.comments.is_empty() {
-
            return Err(Error::Validate("no comments found"));
-
        }
-
        Ok(())
-
    }
-

-
    fn apply<R: ReadRepository>(&mut self, op: Op<Action>, _repo: &R) -> Result<(), Error> {
-
        let id = op.id;
+
    fn init<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
        let author = op.author;
+
        let entry = op.id;
        let timestamp = op.timestamp;
+
        let mut actions = op.actions.into_iter();
+
        let Some(Action::Comment { body, reply_to: None }) = actions.next() else {
+
            return Err(Error::Init("missing initial comment"));
+
        };
+

+
        let mut thread = Thread::default();
+
        comment(
+
            &mut thread,
+
            entry,
+
            author,
+
            timestamp,
+
            body,
+
            None,
+
            None,
+
            vec![],
+
        )?;
+

+
        for action in actions {
+
            thread.action(action, entry, author, timestamp, op.identity, repo)?;
+
        }
+
        Ok(thread)
+
    }

+
    fn apply<R: ReadRepository>(&mut self, op: Op<Action>, repo: &R) -> Result<(), Error> {
        for action in op.actions {
-
            match action {
-
                Action::Comment { body, reply_to } => {
-
                    comment(self, id, author, timestamp, body, reply_to, None, vec![])?;
-
                }
-
                Action::Edit { id, body } => {
-
                    edit(self, op.id, id, timestamp, body, vec![])?;
-
                }
-
                Action::Redact { id } => {
-
                    redact(self, op.id, id)?;
-
                }
-
                Action::React {
-
                    to,
-
                    reaction,
-
                    active,
-
                } => {
-
                    react(self, op.id, author, to, reaction, active)?;
-
                }
-
            }
+
            self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
        }
        Ok(())
    }
@@ -527,23 +560,23 @@ mod tests {

        /// Create a new comment.
        pub fn comment(&mut self, body: &str, reply_to: Option<CommentId>) -> Op<Action> {
-
            self.op::<Thread>(Action::Comment {
+
            self.op::<Thread>([Action::Comment {
                body: String::from(body),
                reply_to,
-
            })
+
            }])
        }

        /// Create a new redaction.
        pub fn redact(&mut self, id: CommentId) -> Op<Action> {
-
            self.op::<Thread>(Action::Redact { id })
+
            self.op::<Thread>([Action::Redact { id }])
        }

        /// Edit a comment.
        pub fn edit(&mut self, id: CommentId, body: &str) -> Op<Action> {
-
            self.op::<Thread>(Action::Edit {
+
            self.op::<Thread>([Action::Edit {
                id,
                body: body.to_owned(),
-
            })
+
            }])
        }
    }

@@ -615,10 +648,10 @@ mod tests {
        let time = Timestamp::now();

        let mut a = test::history::<Thread, _>(
-
            &Action::Comment {
+
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
-
            },
+
            }],
            time,
            &alice,
        );
@@ -682,10 +715,10 @@ mod tests {
        let time = Timestamp::now();

        let mut a = test::history::<Thread, _>(
-
            &Action::Comment {
+
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
-
            },
+
            }],
            time,
            &alice,
        );
@@ -715,10 +748,10 @@ mod tests {
        let timestamp = Timestamp::from_secs(timestamp);

        let h0 = test::history::<Thread, _>(
-
            &Action::Comment {
+
            &[Action::Comment {
                body: "Thread root".to_owned(),
                reply_to: None,
-
            },
+
            }],
            timestamp,
            &alice,
        );