Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli/cob: Create and Update, External COBs
Merged lorenz opened 1 year ago
  • Rewrite command/option validation
  • Introduce rad cob create to create a new COB
  • Introduce rad cob update to append actions to a COB
  • Allow COBs with external evaluation via rad-cob-* helpers

Recording of a very early version, where rad cob show still is broken: https://asciinema.org/a/9m7QkOuyaeKcJ3HCRdnA90yKk

21 files changed +1353 -228 30182233 f30760d6
modified CONTRIBUTING.md
@@ -36,6 +36,9 @@ Make sure all tests are passing with:

    $ cargo test --workspace

+
Some tests require `jq`. If `jq` is not detected, these tests will succeed
+
without effectively testing anything.
+

## Checking the docs

If you make documentation changes, you may want to check whether there are any
modified flake.nix
@@ -179,7 +179,7 @@
              cargoExtraArgs = "-p ${name}";
              doCheck = false;

-
              nativeBuildInputs = with pkgs; [asciidoctor installShellFiles];
+
              nativeBuildInputs = with pkgs; [asciidoctor installShellFiles jq];
              postInstall = ''
                for page in ${lib.escapeShellArgs pages}; do
                  asciidoctor -d manpage -b manpage $page
modified radicle-cli-test/src/lib.rs
@@ -170,13 +170,19 @@ pub struct TestFormula {
impl TestFormula {
    pub fn new(cwd: PathBuf) -> Self {
        Self {
-
            cwd,
+
            cwd: cwd.clone(),
            env: HashMap::new(),
            homes: HashMap::new(),
            tests: Vec::new(),
            subs: Substitutions::new(),
            bins: env::var("PATH")
-
                .map(|p| p.split(':').map(PathBuf::from).collect())
+
                .map(|env_path| {
+
                    let mut bins: Vec<PathBuf> = env_path.split(':').map(PathBuf::from).collect();
+
                    // Add current working directory to `$PATH`,
+
                    // this makes it more convenient to execute scripts during testing.
+
                    bins.push(cwd);
+
                    bins
+
                })
                .unwrap_or_default(),
        }
    }
@@ -534,7 +540,9 @@ $ rad sync
        .as_bytes()
        .to_owned();

-
        let mut actual = TestFormula::new(PathBuf::new());
+
        let cwd = PathBuf::from("radicle-cli-test");
+

+
        let mut actual = TestFormula::new(cwd.clone());
        let path = Path::new("test.md").to_path_buf();
        actual
            .read(path.as_path(), io::BufReader::new(io::Cursor::new(input)))
@@ -542,14 +550,18 @@ $ rad sync

        let expected = TestFormula {
            homes: HashMap::new(),
-
            cwd: PathBuf::new(),
+
            cwd: cwd.clone(),
            env: HashMap::new(),
            subs: Substitutions::new(),
-
            bins: env::var("PATH")
-
                .unwrap()
-
                .split(':')
-
                .map(PathBuf::from)
-
                .collect(),
+
            bins: {
+
                let mut bins: Vec<_> = env::var("PATH")
+
                    .unwrap_or_default()
+
                    .split(':')
+
                    .map(PathBuf::from)
+
                    .collect();
+
                bins.push(cwd);
+
                bins
+
            },
            tests: vec![
                Test {
                    context: vec![String::from("Let's try to track @dave and @sean:")],
added radicle-cli/examples/rad-cob-multiset
@@ -0,0 +1,32 @@
+
#! /usr/bin/env -S jq --from-file --sort-keys --compact-output
+
#
+
# Models a multiset as a JSON object.
+
#
+
# Takes an operation which contains actions, each in one of two shapes:
+
#
+
# Add x:
+
#
+
#   { "+": $x }
+
#
+
# Remove x:
+
#
+
#   { "-": $x }
+
#
+
# (where `$x` is a string).
+
#
+
# These actions are reduced to a multiset represented by an
+
# object. The key being the item to count (corresponding to `$x` above),
+
# and the value being the count itself (corresponding to how many times
+
# `$x` was added, minus how many times `$x` was removed).
+
#
+
# Errors if any unrecognizable action is encountered.
+
#
+
# For an example, see `rad-cob-multiset.md`.
+
reduce
+
    .op.actions[]
+
as {"+": $p, "-": $m} (
+
    .value;
+
    .[$p // $m // ("invalid" | halt_error)] |= (
+
        [. + (if $p then 1 else -1 end), 0] | max
+
    )
+
)
added radicle-cli/examples/rad-cob-multiset.md
@@ -0,0 +1,72 @@
+
This example demonstrates handling of arbitrary Collaborative Objects (COBs) by means of an external program.
+
The external program is called a "COB helper" (analogous to "remote helpers" that can be used to extend Git).
+
It consumes the current state of the COB and at least one operation (all in [JSON]) to be applied to it via files whose names are passed as arguments.
+
It returns the resulting COB by printing it to standard output in [JSON].
+

+
For the sake of example, consider a simplified shopping list, where every item on the list is associated with an integral quantity:
+

+
 - 5 Bananas
+
 - 3 Zucchini
+
 - 1 Bar of Chocolate
+

+
One data structure that maps to this concept very well is a [multiset] (sometimes also called "bag").
+
So, let us introduce a new COB type with the name `com.example.multiset` that implements multisets, which we will then use to model our shopping list.
+

+
The COB we implement should allow two actions:
+

+
 - The action `+`, which adds an item or, if already present, increases the associated quantity.
+
 - The action `-`, which decreases the associated quantity of an item, if it is non-zero.
+

+
We model actions as objects in [JSON], and a sequence of actions in [JSON Lines].
+
An example sequence of actions looks as follows:
+

+
``` ./groceries.jsonl
+
{ "+": "jelly" }
+
{ "+": "peanut butter" }
+
{ "-": "jelly" }
+
{ "-": "jelly" }
+
{ "+": "salad" }
+
{ "+": "salad" }
+
```
+

+
Starting with an empty grocery list, the expected result after evaluating all actions is:
+

+
 - 0 Jelly (this could be omitted)
+
 - 1 Peanut Butter
+
 - 2 Salad
+

+
We have a COB helper, named `rad-cob-multiset`, that implements evaluation of these actions using [jq].
+
It reads the current state of the grocery list and operations containing actions from files given as arguments and writes the resulting grocery list to standard output.
+

+
We do not invoke the program directly, but instead use `rad cob create`:
+

+
```
+
$ rad cob create --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --message "Create grocery shopping multiset" groceries.jsonl
+
9bba8e6f83ef56b11151ef6ad02cc4595f982aab
+
```
+

+
We can verify that the COB evaluated as expected:
+

+
```
+
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
+
{"jelly":0,"peanut butter":1,"salad":2}
+
```
+

+
To apply actions to COBs that already exist, we can use `rad cob update`:
+

+
```
+
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab --message "Modify grocery shopping multiset" groceries.jsonl
+
d36aac77be13c1ca80edbfe7b7bf9b42c723f019
+
```
+

+
Again, we verify the result with `rad cob show`:
+

+
```
+
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
+
{"jelly":0,"peanut butter":2,"salad":4}
+
```
+

+
[multisets]: https://wikipedia.org/wiki/Multiset
+
[JSON]: https://tools.ietf.org/html/std90
+
[JSON Lines]: https://jsonlines.org/
+
[jq]: https://github.com/jqlang/jq

\ No newline at end of file
added radicle-cli/examples/rad-cob-update-identity.md
@@ -0,0 +1,6 @@
+
Updating the repository identity via `rad cob update` is forbidden:
+

+
``` (fail)
+
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.id --object 0656c217f917c3e06234771e9ecae53aba5e173e --message "Danger" /dev/null
+
✗ Error: Update of collaborative objects of type xyz.radicle.id is not supported.
+
```

\ No newline at end of file
added radicle-cli/examples/rad-cob-update.md
@@ -0,0 +1,178 @@
+
First off, we set up a patch.
+

+
```
+
$ git checkout -b changes
+
$ touch README.md
+
$ git add README.md
+
$ git commit --message "Add README, just for the fun"
+
[changes 03c02af] Add README, just for the fun
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 README.md
+
```
+

+
``` (stderr)
+
$ git push rad -o patch.message="Add README, just for the fun" HEAD:refs/patches
+
✓ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 opened
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
 * [new reference]   HEAD -> refs/patches
+
```
+

+
```
+
$ touch LICENSE
+
$ git add LICENSE
+
$ git commit -v -m "Define the LICENSE"
+
[changes 8945f61] Define the LICENSE
+
 1 file changed, 0 insertions(+), 0 deletions(-)
+
 create mode 100644 LICENSE
+
```
+

+
``` (stderr)
+
$ git push -f -o patch.message="Add License"
+
✓ Patch 89f7afb updated to revision 5d78dd5376453e25df5988ec86951c99cb73742c
+
To compare against your previous revision 89f7afb, run:
+

+
   git range-diff f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 03c02af4b12a593d17a06d38fae50a57fc3c339a 8945f6189adf027892c85ac57f7e9341049c2537
+

+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   03c02af..8945f61  changes -> patches/89f7afb1511b976482b21f6b2f39aef7f4fb88a2
+
```
+

+
Let's look at the patch, to see what it looks like before editing it:
+

+
```
+
$ rad patch show 89f7afb
+
╭─────────────────────────────────────────────────────────────────────╮
+
│ Title     Add README, just for the fun                              │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
+
│ Author    alice (you)                                               │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Branches  changes                                                   │
+
│ Commits   ahead 2, behind 0                                         │
+
│ Status    open                                                      │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                                          │
+
│ 03c02af Add README, just for the fun                                │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ ● opened by alice (you) (03c02af) now                               │
+
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
+
╰─────────────────────────────────────────────────────────────────────╯
+
```
+

+
We can change the title and description of the patch itself by using a
+
multi-line message (using two `--message` options here):
+

+
```
+
$ rad patch edit 89f7afb --message "Add Metadata" --message "Add README & LICENSE" --no-announce
+
$ rad patch show 89f7afb
+
╭─────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Metadata                                              │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
+
│ Author    alice (you)                                               │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Branches  changes                                                   │
+
│ Commits   ahead 2, behind 0                                         │
+
│ Status    open                                                      │
+
│                                                                     │
+
│ Add README & LICENSE                                                │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                                          │
+
│ 03c02af Add README, just for the fun                                │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ ● opened by alice (you) (03c02af) now                               │
+
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
+
╰─────────────────────────────────────────────────────────────────────╯
+
```
+

+
We prepare the file `revision-edit.json` which contains one action (thus one line) to be applied.
+

+
``` ./revision-edit.jsonl
+
{"type": "revision.edit", "description": "Add README and LICENSE", "revision": "89f7afb1511b976482b21f6b2f39aef7f4fb88a2"}
+
```
+

+
We now use `rad cob update` to edit the patch another time, rewriting the description.
+
The action itself is of type `revision.edit` and carries the parameters `revision`,
+
specifying the revision for which the description should be changed, and `description`,
+
specifying the new description.
+

+
```
+
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Edit patch" revision-edit.jsonl
+
79b816e92735c49b33d93a31890fdf040b36234c
+
$ rad patch show --verbose 89f7afb
+
╭─────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Metadata                                              │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
+
│ Author    alice (you)                                               │
+
│ Head      8945f6189adf027892c85ac57f7e9341049c2537                  │
+
│ Base      f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354                  │
+
│ Branches  changes                                                   │
+
│ Commits   ahead 2, behind 0                                         │
+
│ Status    open                                                      │
+
│                                                                     │
+
│ Add README and LICENSE                                              │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ 8945f61 Define the LICENSE                                          │
+
│ 03c02af Add README, just for the fun                                │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ ● opened by alice (you) (03c02af) now                               │
+
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
+
╰─────────────────────────────────────────────────────────────────────╯
+
```
+

+
Notice that the patch now has the description `Add README and LICENSE`.
+

+
We may use `rad cob update` to create a new revision altogether, as well.
+
Let's create yet another commit, an empty one this time, and do that.
+

+
```
+
$ git commit --allow-empty --message="Dummy commit for a new revision"
+
[changes f1339dd] Dummy commit for a new revision
+
```
+

+
We prepare the file `revision-create.jsonl` which contains one action.
+

+
``` ./revision.jsonl
+
{"type": "revision", "description": "A new revision", "base": "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354", "oid": "f1339dd109e538c6b3a7fed3e72403e1b4db08c9"}
+
```
+

+
Attempting to create the new revision right away would fail:
+

+
``` (fail)
+
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
+
✗ Error: store: update error: failed to read 'f1339dd109e538c6b3a7fed3e72403e1b4db08c9' from git odb
+
```
+

+
Since we are not using the remote helper `git-remote-rad` here, we need to push
+
the new commit to storage manually. See `fn patch_open` in `/radicle-remote-helper/src/push.rs`
+
for more details.
+

+
```
+
$ git push rad HEAD:tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
+
$ git push rad :tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
+
```
+

+
Now we can invoke `rad cob update`:
+

+
```
+
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
+
2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c
+
$ rad patch show 89f7afb
+
╭─────────────────────────────────────────────────────────────────────╮
+
│ Title     Add Metadata                                              │
+
│ Patch     89f7afb1511b976482b21f6b2f39aef7f4fb88a2                  │
+
│ Author    alice (you)                                               │
+
│ Head      f1339dd109e538c6b3a7fed3e72403e1b4db08c9                  │
+
│ Branches  changes                                                   │
+
│ Commits   ahead 3, behind 0                                         │
+
│ Status    open                                                      │
+
│                                                                     │
+
│ Add README and LICENSE                                              │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ f1339dd Dummy commit for a new revision                             │
+
│ 8945f61 Define the LICENSE                                          │
+
│ 03c02af Add README, just for the fun                                │
+
├─────────────────────────────────────────────────────────────────────┤
+
│ ● opened by alice (you) (03c02af) now                               │
+
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
+
│ ↑ updated to 2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c (f1339dd) now │
+
╰─────────────────────────────────────────────────────────────────────╯
+
```

\ No newline at end of file
modified radicle-cli/src/commands/cob.rs
@@ -1,21 +1,19 @@
use std::ffi::OsString;
-
use std::io;
-
use std::io::Write;
+
use std::path::PathBuf;
use std::str::FromStr;
+
use std::{fs, io};
+

+
use anyhow::{anyhow, bail};

-
use anyhow::anyhow;
use chrono::prelude::*;
+

use nonempty::NonEmpty;
+

use radicle::cob;
-
use radicle::cob::Op;
-
use radicle::identity::Identity;
-
use radicle::issue::cache::Issues;
-
use radicle::patch::cache::Patches;
-
use radicle::prelude::RepoId;
+
use radicle::cob::store::CobAction;
+
use radicle::prelude::*;
use radicle::storage::git;
-
use radicle::storage::ReadStorage;
-
use radicle::Profile;
-
use radicle_cob::object::collaboration::list;
+

use serde_json::json;

use crate::git::Rev;
@@ -30,33 +28,47 @@ pub const HELP: Help = Help {
Usage

    rad cob <command> [<option>...]
-
    rad cob list --repo <rid> --type <typename>
-
    rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
-
    rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
+

+
    rad cob create  --repo <rid> --type <typename> <filename> [<option>...]
+
    rad cob list    --repo <rid> --type <typename>
+
    rad cob log     --repo <rid> --type <typename> --object <oid> [<option>...]
    rad cob migrate [<option>...]
+
    rad cob show    --repo <rid> --type <typename> --object <oid> [<option>...]
+
    rad cob update  --repo <rid> --type <typename> --object <oid> <filename>
+
                    [<option>...]

Commands

-
    list                       List all COBs of a given type (--object is not needed)
-
    log                        Print a log of all raw operations on a COB
-
    migrate                    Migrate the COB database to the latest version
+
    create                      Create a new COB of a given type given initial actions
+
    list                        List all COBs of a given type (--object is not needed)
+
    log                         Print a log of all raw operations on a COB
+
    migrate                     Migrate the COB database to the latest version
+
    update                      Add actions to a COB
+
    show                        Print the state of COBs
+

+
Create, Update options
+

+
    --embed-file <name> <path>  Supply embed of given name via file at given path
+
    --embed-hash <name> <oid>   Supply embed of given name via object ID of blob

Log options

-
    --format (pretty | json)   Desired output format (default: pretty)
+
    --format (pretty | json)    Desired output format (default: pretty)

Show options

-
    --format json              Desired output format (default: json)
+
    --format json               Desired output format (default: json)

Other options

-
    --help                     Print help
+
    --help                      Print help
"#,
};

-
#[derive(PartialEq)]
+
#[derive(Clone, Copy, PartialEq)]
enum OperationName {
+
    Update,
+
    Create,
    List,
    Log,
    Migrate,
@@ -64,20 +76,36 @@ enum OperationName {
}

enum Operation {
+
    Create {
+
        rid: RepoId,
+
        type_name: FilteredTypeName,
+
        message: String,
+
        actions: PathBuf,
+
        embeds: Vec<Embed>,
+
    },
    List {
-
        repo: RepoId,
-
        type_name: cob::TypeName,
+
        rid: RepoId,
+
        type_name: FilteredTypeName,
    },
    Log {
-
        repo: RepoId,
-
        rev: Rev,
-
        type_name: cob::TypeName,
+
        rid: RepoId,
+
        type_name: FilteredTypeName,
+
        oid: Rev,
+
        format: Format,
    },
    Migrate,
    Show {
-
        repo: RepoId,
-
        revs: Vec<Rev>,
-
        type_name: cob::TypeName,
+
        rid: RepoId,
+
        type_name: FilteredTypeName,
+
        oids: Vec<Rev>,
+
    },
+
    Update {
+
        rid: RepoId,
+
        type_name: FilteredTypeName,
+
        oid: Rev,
+
        message: String,
+
        actions: PathBuf,
+
        embeds: Vec<Embed>,
    },
}

@@ -88,132 +116,290 @@ enum Format {

pub struct Options {
    op: Operation,
-
    format: Format,
+
}
+

+
/// A precursor to [`cob::Embed`] used for parsing
+
/// that can be initialized without relying on a [`git::Repository`].
+
struct Embed {
+
    name: String,
+
    content: EmbedContent,
+
}
+

+
enum EmbedContent {
+
    Path(PathBuf),
+
    Hash(Rev),
+
}
+

+
/// A thin wrapper around [`cob::TypeName`] used for parsing.
+
/// Well known COB type names are captured as variants,
+
/// with [`FilteredTypeName::Other`] as an escape hatch for type names
+
/// that are not well known.
+
enum FilteredTypeName {
+
    Issue,
+
    Patch,
+
    Identity,
+
    Other(cob::TypeName),
+
}
+

+
impl From<cob::TypeName> for FilteredTypeName {
+
    fn from(value: cob::TypeName) -> Self {
+
        if value == *cob::issue::TYPENAME {
+
            FilteredTypeName::Issue
+
        } else if value == *cob::patch::TYPENAME {
+
            FilteredTypeName::Patch
+
        } else if value == *cob::identity::TYPENAME {
+
            FilteredTypeName::Identity
+
        } else {
+
            FilteredTypeName::Other(value)
+
        }
+
    }
+
}
+

+
impl AsRef<cob::TypeName> for FilteredTypeName {
+
    fn as_ref(&self) -> &cob::TypeName {
+
        match self {
+
            FilteredTypeName::Issue => &cob::issue::TYPENAME,
+
            FilteredTypeName::Patch => &cob::patch::TYPENAME,
+
            FilteredTypeName::Identity => &cob::identity::TYPENAME,
+
            FilteredTypeName::Other(value) => value,
+
        }
+
    }
+
}
+

+
impl std::fmt::Display for FilteredTypeName {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        self.as_ref().fmt(f)
+
    }
+
}
+

+
impl Embed {
+
    fn try_into_bytes(self, repo: &git::Repository) -> anyhow::Result<cob::Embed<cob::Uri>> {
+
        Ok(match self.content {
+
            EmbedContent::Hash(hash) => cob::Embed {
+
                name: self.name,
+
                content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
+
            },
+
            EmbedContent::Path(path) => {
+
                cob::Embed::store(self.name, &std::fs::read(path)?, &repo.backend)?
+
            }
+
        })
+
    }
}

impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;
+
        use term::args::string;
+
        use OperationName::*;

        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut type_name: Option<cob::TypeName> = None;
-
        let mut revs: Vec<Rev> = vec![];
+

+
        let op = match parser.next()? {
+
            None | Some(Long("help") | Short('h')) => {
+
                return Err(Error::Help.into());
+
            }
+
            Some(Value(val)) => match val.to_string_lossy().as_ref() {
+
                "update" => Update,
+
                "create" => Create,
+
                "list" => List,
+
                "log" => Log,
+
                "migrate" => Migrate,
+
                "show" => Show,
+
                unknown => bail!("unknown operation '{unknown}'"),
+
            },
+
            Some(arg) => return Err(anyhow!(arg.unexpected())),
+
        };
+

+
        let mut type_name: Option<FilteredTypeName> = None;
+
        let mut oids: Vec<Rev> = vec![];
        let mut rid: Option<RepoId> = None;
-
        let mut format: Option<Format> = None;
+
        let mut format: Format = Format::Pretty;
+
        let mut message: Option<String> = None;
+
        let mut embeds: Vec<Embed> = vec![];
+
        let mut actions: Option<PathBuf> = None;

        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "list" => op = Some(OperationName::List),
-
                    "log" => op = Some(OperationName::Log),
-
                    "migrate" => op = Some(OperationName::Migrate),
-
                    "show" => op = Some(OperationName::Show),
-
                    unknown => anyhow::bail!("unknown operation '{unknown}'"),
-
                },
-
                Long("type") | Short('t') => {
-
                    let v = parser.value()?;
-
                    let v = term::args::string(&v);
-
                    let v = cob::TypeName::from_str(&v)?;
-

-
                    type_name = Some(v);
+
            match (&op, &arg) {
+
                (_, Long("help") | Short('h')) => {
+
                    return Err(Error::Help.into());
                }
-
                Long("object") => {
-
                    let v = parser.value()?;
-
                    let v = term::args::string(&v);
-

-
                    revs.push(Rev::from(v));
+
                (_, Long("repo") | Short('r')) => {
+
                    rid = Some(term::args::rid(&parser.value()?)?);
+
                }
+
                (_, Long("type") | Short('t')) => {
+
                    let v = string(&parser.value()?);
+
                    type_name = Some(FilteredTypeName::from(cob::TypeName::from_str(&v)?));
+
                }
+
                (Update | Log | Show, Long("object") | Short('o')) => {
+
                    let v = string(&parser.value()?);
+
                    oids.push(Rev::from(v));
+
                }
+
                (Update | Create, Long("message") | Short('m')) => {
+
                    message = Some(string(&parser.value()?));
                }
-
                Long("repo") => {
-
                    let v = parser.value()?;
-
                    let v = term::args::rid(&v)?;
+
                (Log | Show | Update, Long("format")) => {
+
                    format = match (op, string(&parser.value()?).as_ref()) {
+
                        (Log, "pretty") => Format::Pretty,
+
                        (Log | Show | Update, "json") => Format::Json,
+
                        (_, unknown) => bail!("unknown format '{unknown}'"),
+
                    };
+
                }
+
                (Update | Create, Long("embed-file")) => {
+
                    let mut values = parser.values()?;
+

+
                    let name = values
+
                        .next()
+
                        .map(|s| term::args::string(&s))
+
                        .ok_or(anyhow!("expected name of embed"))?;

-
                    rid = Some(v);
+
                    let content = EmbedContent::Path(PathBuf::from(
+
                        values
+
                            .next()
+
                            .ok_or(anyhow!("expected path to file to embed"))?,
+
                    ));
+

+
                    embeds.push(Embed { name, content });
                }
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
+
                (Update | Create, Long("embed-hash")) => {
+
                    let mut values = parser.values()?;
+

+
                    let name = values
+
                        .next()
+
                        .map(|s| term::args::string(&s))
+
                        .ok_or(anyhow!("expected name of embed"))?;
+

+
                    let content = EmbedContent::Hash(Rev::from(term::args::string(
+
                        &values
+
                            .next()
+
                            .ok_or(anyhow!("expected hash of file to embed"))?,
+
                    )));
+

+
                    embeds.push(Embed { name, content });
                }
-
                Long("format")
-
                    if op == Some(OperationName::Log) || op == Some(OperationName::Show) =>
-
                {
-
                    let v: String = term::args::string(&parser.value()?);
-
                    match v.as_ref() {
-
                        "pretty" if op == Some(OperationName::Log) => format = Some(Format::Pretty),
-
                        "json" => format = Some(Format::Json),
-
                        unknown => anyhow::bail!("unknown format '{unknown}'"),
-
                    }
+
                (Update | Create, Value(val)) => {
+
                    actions = Some(PathBuf::from(term::args::string(val)));
                }
-
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
+
                _ => return Err(anyhow!(arg.unexpected())),
            }
        }
-
        let repo = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"));
+

+
        if op == OperationName::Migrate {
+
            return Ok((
+
                Options {
+
                    op: Operation::Migrate,
+
                },
+
                vec![],
+
            ));
+
        }
+

+
        let rid = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?;
        let type_name =
-
            type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"));
+
            type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?;
+

+
        let missing_oid = || anyhow!("an object id must be specified with `--object`");
+
        let missing_message = || anyhow!("a message must be specified with `--message`");

        Ok((
            Options {
-
                op: {
-
                    match op.ok_or_else(|| anyhow!("a command must be specified"))? {
-
                        OperationName::List => Operation::List {
-
                            repo: repo?,
-
                            type_name: type_name?,
-
                        },
-
                        OperationName::Log => Operation::Log {
-
                            repo: repo?,
-
                            rev: revs.pop().ok_or_else(|| {
-
                                anyhow!("an object id must be specified with `--object`")
-
                            })?,
-
                            type_name: type_name?,
-
                        },
-
                        OperationName::Migrate => Operation::Migrate,
-
                        OperationName::Show => {
-
                            if revs.is_empty() {
-
                                anyhow::bail!("an object id must be specified with `--object`")
-
                            }
-
                            Operation::Show {
-
                                repo: repo?,
-
                                revs,
-
                                type_name: type_name?,
-
                            }
+
                op: match op {
+
                    Create => Operation::Create {
+
                        rid,
+
                        type_name,
+
                        message: message.ok_or_else(missing_message)?,
+
                        actions: actions.ok_or_else(|| {
+
                            anyhow!("a file containing initial actions must be specified")
+
                        })?,
+
                        embeds,
+
                    },
+
                    List => Operation::List { rid, type_name },
+
                    Log => Operation::Log {
+
                        rid,
+
                        type_name,
+
                        oid: oids.pop().ok_or_else(missing_oid)?,
+
                        format,
+
                    },
+
                    Migrate => Operation::Migrate,
+
                    Show => {
+
                        if oids.is_empty() {
+
                            return Err(missing_oid());
+
                        }
+
                        Operation::Show {
+
                            rid,
+
                            oids,
+
                            type_name,
                        }
                    }
+
                    Update => Operation::Update {
+
                        rid,
+
                        type_name,
+
                        oid: oids.pop().ok_or_else(missing_oid)?,
+
                        message: message.ok_or_else(missing_message)?,
+
                        actions: actions.ok_or_else(|| {
+
                            anyhow!("a file containing actions must be specified")
+
                        })?,
+
                        embeds,
+
                    },
                },
-
                format: format.unwrap_or(Format::Pretty),
            },
            vec![],
        ))
    }
}

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
    use cob::store::Store;
+
    use FilteredTypeName::*;
+
    use Operation::*;
+

    let profile = ctx.profile()?;
    let storage = &profile.storage;

-
    match options.op {
-
        Operation::List { repo, type_name } => {
-
            let repo = storage.repository(repo)?;
-
            let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
-
            for cob in cobs {
-
                println!("{}", cob.id);
-
            }
-
        }
-
        Operation::Log {
-
            repo,
-
            rev: oid,
+
    match op {
+
        Create {
+
            rid,
            type_name,
+
            message,
+
            embeds,
+
            actions,
        } => {
-
            let repo = storage.repository(repo)?;
-
            let oid = oid.resolve(&repo.backend)?;
-
            let ops = cob::store::ops(&oid, &type_name, &repo)?;
-

-
            for op in ops.into_iter().rev() {
-
                match options.format {
-
                    Format::Json => print_op_json(op)?,
-
                    Format::Pretty => print_op_pretty(op)?,
+
            let signer = &profile.signer()?;
+
            let repo = storage.repository_mut(rid)?;
+

+
            let reader = io::BufReader::new(fs::File::open(actions)?);
+

+
            let embeds = embeds
+
                .into_iter()
+
                .map(|embed| embed.try_into_bytes(&repo))
+
                .collect::<anyhow::Result<Vec<_>>>()?;
+

+
            let oid = match type_name {
+
                Patch => {
+
                    let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
+
                    let actions = read_jsonl_actions(reader)?;
+
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    oid
                }
-
            }
+
                Issue => {
+
                    let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
+
                    let actions = read_jsonl_actions(reader)?;
+
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    oid
+
                }
+
                Identity => anyhow::bail!(
+
                    "Creation of collaborative objects of type {} is not supported.",
+
                    &type_name
+
                ),
+
                Other(type_name) => {
+
                    let store: Store<cob::external::External, _> =
+
                        Store::open_for(&type_name, &repo)?;
+
                    let actions = read_jsonl_actions(reader)?;
+
                    let (oid, _) = store.create(&message, actions, embeds, signer)?;
+
                    oid
+
                }
+
            };
+
            println!("{}", oid);
        }
-
        Operation::Migrate => {
+
        Migrate => {
            let mut db = profile.cobs_db_mut()?;
            if db.check_version().is_ok() {
                term::success!("Collaborative objects database is already up to date");
@@ -224,71 +410,175 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                );
            }
        }
-
        Operation::Show {
-
            repo,
-
            revs,
+
        List { rid, type_name } => {
+
            let repo = storage.repository(rid)?;
+
            let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
+
            for cob in cobs {
+
                println!("{}", cob.id);
+
            }
+
        }
+
        Log {
+
            rid,
            type_name,
+
            oid,
+
            format,
        } => {
-
            let repo = storage.repository(repo)?;
+
            let repo = storage.repository(rid)?;
+
            let oid = oid.resolve(&repo.backend)?;
+
            let ops = cob::store::ops(&oid, type_name.as_ref(), &repo)?;

-
            if let Err(e) = show(revs, &repo, type_name, &profile) {
-
                if let Some(err) = e.downcast_ref::<io::Error>() {
-
                    if err.kind() == io::ErrorKind::BrokenPipe {
+
            for op in ops.into_iter().rev() {
+
                match format {
+
                    Format::Json => print_op_json(op)?,
+
                    Format::Pretty => print_op_pretty(op)?,
+
                }
+
            }
+
        }
+
        Show {
+
            rid,
+
            oids,
+
            type_name,
+
        } => {
+
            let repo = storage.repository(rid)?;
+
            if let Err(e) = show(oids, &repo, type_name, &profile) {
+
                if let Some(err) = e.downcast_ref::<std::io::Error>() {
+
                    if err.kind() == std::io::ErrorKind::BrokenPipe {
                        return Ok(());
                    }
                }
                return Err(e);
            }
        }
-
    }
+
        Update {
+
            rid,
+
            type_name,
+
            oid,
+
            message,
+
            actions,
+
            embeds,
+
        } => {
+
            let signer = &profile.signer()?;
+
            let repo = storage.repository_mut(rid)?;
+
            let reader = io::BufReader::new(fs::File::open(actions)?);
+
            let oid = &oid.resolve(&repo.backend)?;
+
            let embeds = embeds
+
                .into_iter()
+
                .map(|embed| embed.try_into_bytes(&repo))
+
                .collect::<anyhow::Result<Vec<_>>>()?;
+

+
            let oid = match type_name {
+
                Patch => {
+
                    let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
+
                    let mut patches = profile.patches_mut(&repo)?;
+
                    let mut patch = patches.get_mut(oid)?;
+
                    patch.transaction(&message, &profile.signer()?, |tx| {
+
                        tx.extend(actions)?;
+
                        tx.embed(embeds)?;
+
                        Ok(())
+
                    })?
+
                }
+
                Issue => {
+
                    let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
+
                    let mut issues = profile.issues_mut(&repo)?;
+
                    let mut issue = issues.get_mut(oid)?;
+
                    issue.transaction(&message, &profile.signer()?, |tx| {
+
                        tx.extend(actions)?;
+
                        tx.embed(embeds)?;
+
                        Ok(())
+
                    })?
+
                }
+
                Identity => anyhow::bail!(
+
                    "Update of collaborative objects of type {} is not supported.",
+
                    &type_name
+
                ),
+
                Other(type_name) => {
+
                    use cob::external::{Action, External};
+
                    let actions: Vec<Action> = read_jsonl(reader)?;
+
                    let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
+
                    let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
+
                    let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
+
                    oid
+
                }
+
            };

+
            println!("{}", oid);
+
        }
+
    }
    Ok(())
}

fn show(
-
    revs: Vec<Rev>,
+
    oids: Vec<Rev>,
    repo: &git::Repository,
-
    type_name: cob::TypeName,
+
    type_name: FilteredTypeName,
    profile: &Profile,
) -> Result<(), anyhow::Error> {
+
    use io::Write as _;
    let mut stdout = std::io::stdout();

-
    if type_name == cob::patch::TYPENAME.clone() {
-
        let patches = term::cob::patches(profile, repo)?;
-
        for oid in revs {
-
            let oid = &oid.resolve(&repo.backend)?;
-
            let Some(patch) = patches.get(oid)? else {
-
                anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
-
            };
-
            serde_json::to_writer(&stdout, &patch)?;
-
            stdout.write_all(b"\n")?;
+
    match type_name {
+
        FilteredTypeName::Identity => {
+
            use cob::identity;
+
            for oid in oids {
+
                let oid = &oid.resolve(&repo.backend)?;
+
                let Some(cob) = cob::get::<identity::Identity, _>(repo, type_name.as_ref(), oid)?
+
                else {
+
                    bail!(cob::store::Error::NotFound(
+
                        type_name.as_ref().clone(),
+
                        *oid
+
                    ));
+
                };
+
                serde_json::to_writer(&stdout, &cob.object)?;
+
                stdout.write_all(b"\n")?;
+
            }
        }
-
    } else if type_name == cob::issue::TYPENAME.clone() {
-
        let issues = term::cob::issues(profile, repo)?;
-
        for oid in revs {
-
            let oid = &oid.resolve(&repo.backend)?;
-
            let Some(issue) = issues.get(oid)? else {
-
                anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
-
            };
-
            serde_json::to_writer(&stdout, &issue)?;
-
            stdout.write_all(b"\n")?;
+
        FilteredTypeName::Issue => {
+
            use radicle::issue::cache::Issues as _;
+
            let issues = term::cob::issues(profile, repo)?;
+
            for oid in oids {
+
                let oid = &oid.resolve(&repo.backend)?;
+
                let Some(issue) = issues.get(oid)? else {
+
                    bail!(cob::store::Error::NotFound(
+
                        type_name.as_ref().clone(),
+
                        *oid
+
                    ))
+
                };
+
                serde_json::to_writer(&stdout, &issue)?;
+
                stdout.write_all(b"\n")?;
+
            }
        }
-
    } else if type_name == cob::identity::TYPENAME.clone() {
-
        for oid in revs {
-
            let oid = &oid.resolve(&repo.backend)?;
-
            let Some(cob) = cob::get::<Identity, _>(repo, &type_name, oid)? else {
-
                anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
-
            };
-
            serde_json::to_writer(&stdout, &cob.object)?;
-
            stdout.write_all(b"\n")?;
+
        FilteredTypeName::Patch => {
+
            use radicle::patch::cache::Patches as _;
+
            let patches = term::cob::patches(profile, repo)?;
+
            for oid in oids {
+
                let oid = &oid.resolve(&repo.backend)?;
+
                let Some(patch) = patches.get(oid)? else {
+
                    bail!(cob::store::Error::NotFound(
+
                        type_name.as_ref().clone(),
+
                        *oid
+
                    ));
+
                };
+
                serde_json::to_writer(&stdout, &patch)?;
+
                stdout.write_all(b"\n")?;
+
            }
+
        }
+
        FilteredTypeName::Other(type_name) => {
+
            let store =
+
                cob::store::Store::<cob::external::External, _>::open_for(&type_name, repo)?;
+
            for oid in oids {
+
                let oid = &oid.resolve(&repo.backend)?;
+
                let cob = store
+
                    .get(oid)?
+
                    .ok_or_else(|| anyhow!(cob::store::Error::NotFound(type_name.clone(), *oid)))?;
+
                serde_json::to_writer(&stdout, &cob)?;
+
                stdout.write_all(b"\n")?;
+
            }
        }
-
    } else {
-
        anyhow::bail!("the type name '{type_name}' is unknown");
    }
    Ok(())
}

-
fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
+
fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
    let time = DateTime::<Utc>::from(
        std::time::UNIX_EPOCH + std::time::Duration::from_secs(op.timestamp.as_secs()),
    )
@@ -317,18 +607,46 @@ fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
    Ok(())
}

-
fn print_op_json(op: Op<Vec<u8>>) -> anyhow::Result<()> {
+
fn print_op_json(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
    let mut ser = json!(op);
-
    ser.as_object_mut().unwrap().insert(
-
        "actions".to_string(),
-
        json!(op
-
            .actions
-
            .iter()
-
            .map(|action: &Vec<u8>| -> Result<serde_json::Value, _> {
-
                serde_json::from_slice(action)
-
            })
-
            .collect::<Result<Vec<serde_json::Value>, _>>()?),
-
    );
+
    ser.as_object_mut()
+
        .expect("ops must serialize to objects")
+
        .insert(
+
            "actions".to_string(),
+
            json!(op
+
                .actions
+
                .iter()
+
                .map(|action: &Vec<u8>| -> Result<serde_json::Value, _> {
+
                    serde_json::from_slice(action)
+
                })
+
                .collect::<Result<Vec<serde_json::Value>, _>>()?),
+
        );
    term::print(ser);
    Ok(())
}
+

+
/// Naive implementation for reading JSONL streams,
+
/// see <https://jsonlines.org/>.
+
fn read_jsonl<R, T>(reader: io::BufReader<R>) -> anyhow::Result<Vec<T>>
+
where
+
    R: io::Read,
+
    T: serde::de::DeserializeOwned,
+
{
+
    use io::BufRead as _;
+
    let mut result: Vec<T> = Vec::new();
+
    for line in reader.lines() {
+
        result.push(serde_json::from_str(&line?)?);
+
    }
+
    Ok(result)
+
}
+

+
/// Tiny utility to read a [`NonEmpty`] of COB actions.
+
/// This is used for `rad cob create` and `rad cob update`.
+
fn read_jsonl_actions<R, A>(reader: io::BufReader<R>) -> anyhow::Result<NonEmpty<A>>
+
where
+
    R: io::Read,
+
    A: CobAction + serde::de::DeserializeOwned,
+
{
+
    NonEmpty::from_vec(read_jsonl(reader)?)
+
        .ok_or_else(|| anyhow!("at least one action is required"))
+
}
modified radicle-cli/tests/commands.rs
@@ -159,6 +159,92 @@ fn rad_issue() {
}

#[test]
+
fn rad_cob_update() {
+
    let mut environment = Environment::new();
+
    let profile = environment.profile(config::profile("alice"));
+
    let working = environment.tmp().join("working");
+
    let home = &profile.home;
+

+
    let base = Path::new(env!("CARGO_MANIFEST_DIR"));
+

+
    std::fs::create_dir_all(base).unwrap();
+
    std::fs::create_dir_all(working.clone()).unwrap();
+

+
    // Setup a test repository.
+
    fixtures::repository(&working);
+

+
    test("examples/rad-init.md", &working, Some(home), []).unwrap();
+
    test("examples/rad-cob-update.md", &working, Some(home), []).unwrap();
+
}
+

+
#[test]
+
fn rad_cob_update_identity() {
+
    let mut environment = Environment::new();
+
    let profile = environment.profile(config::profile("alice"));
+
    let working = environment.tmp().join("working");
+
    let home = &profile.home;
+

+
    let base = Path::new(env!("CARGO_MANIFEST_DIR"));
+

+
    std::fs::create_dir_all(base).unwrap();
+
    std::fs::create_dir_all(working.clone()).unwrap();
+

+
    // Setup a test repository.
+
    fixtures::repository(&working);
+

+
    test("examples/rad-init.md", &working, Some(home), []).unwrap();
+
    test(
+
        "examples/rad-cob-update-identity.md",
+
        &working,
+
        Some(home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
+
fn rad_cob_multiset() {
+
    {
+
        // `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
+
        // We test whether `jq` is installed, and have this test succeed if it is not.
+
        // Programmatic skipping of tests is not supported as of 2024-08.
+

+
        let output = std::process::Command::new("/usr/bin/env")
+
            .arg("jq")
+
            .arg("-V")
+
            .output()
+
            .unwrap();
+

+
        if !output.status.success() {
+
            log::warn!(target: "test", "`jq` not found. Succeeding prematurely. {:?}", output);
+
            return;
+
        }
+
    }
+

+
    let mut environment = Environment::new();
+
    let profile = environment.profile(config::profile("alice"));
+
    let home = &profile.home;
+
    let working = environment.tmp().join("working");
+

+
    let base = Path::new(env!("CARGO_MANIFEST_DIR"));
+
    std::fs::create_dir_all(base).unwrap();
+
    std::fs::create_dir_all(working.clone()).unwrap();
+

+
    // Copy over the script that implements the multiset COB.
+
    std::fs::copy(
+
        base.join("examples").join("rad-cob-multiset"),
+
        working.join("rad-cob-multiset"),
+
    )
+
    .unwrap();
+

+
    // Setup a test repository.
+
    fixtures::repository(&working);
+

+
    test("examples/rad-init.md", &working, Some(home), []).unwrap();
+
    test("examples/rad-cob-multiset.md", &working, Some(home), []).unwrap();
+
}
+

+
#[test]
fn rad_cob_log() {
    let mut environment = Environment::new();
    let profile = environment.profile(config::profile("alice"));
modified radicle-node/src/worker/fetch.rs
@@ -342,7 +342,7 @@ fn update_or_remove<R, C, T>(
) -> Result<(), error::Cache>
where
    R: cob::Store + ReadRepository,
-
    T: cob::Evaluate<R> + cob::store::Cob,
+
    T: cob::Evaluate<R> + cob::store::Cob + cob::store::CobWithType,
    C: cob::cache::Update<T> + cob::cache::Remove<T>,
{
    match store.get(&tid.id) {
modified radicle/src/cob.rs
@@ -1,6 +1,7 @@
#![warn(clippy::unwrap_used)]
pub mod cache;
pub mod common;
+
pub mod external;
pub mod identity;
pub mod issue;
pub mod job;
added radicle/src/cob/external.rs
@@ -0,0 +1,240 @@
+
//! # External Collaborative Objects
+
//!
+
//! This module provides an interface for external helper programs to provide
+
//! the evaluation logic of Collaborative Objects (COBs).
+
//!
+
//! An external COB is one that relies on an external program (so called
+
//! "helper", is an executable file, for example a script, or a binary), that
+
//! implements the evaluation logic for that particular COB:
+
//! Whenever an operation is to be applied to an external COB, the helper is
+
//! invoked with the current state of the COB and the operation to be applied.
+
//! It then returns the new state of the COB, according to its internal logic.
+
//! This concept is borrowed from Git, which supports [remote helpers] and
+
//! [credential helpers].
+
//!
+
//! External COBs must be based on JSON, that is, the COB itself and associated
+
//! actions must serialize to and deserialize from JSON.
+
//! Further, the helper must be able to communicate to Radicle using
+
//! [JSON Lines] (see further details below).
+
//!
+
//! # Invocation
+
//!
+
//! The helper is invoked by Radicle without command line arguments.
+
//! In the future, more arguments might be added.
+
//!
+
//! # Helper Protocol
+
//!
+
//! Radicle and the helper communicate back and forth [JSON Lines] via standard
+
//! streams.
+
//!
+
//!  1. The helper must read and process at least one JSON Line (containing one
+
//!     operation) from standard input, which represents the operation to be
+
//!     applied to the COB, along with possible concurrent operations.
+
//!  2. The helper must write the new state of COB to standard output in a JSON
+
//!     Line.
+
//!  3. The helper may read additional JSON Lines from standard input, these are
+
//!     to be applied "on top of" the previous operations.
+
//!  4. The helper must reply with the state of the COB in a JSON Line after
+
//!     processing each operation.
+
//!  5. The helper must exit with a status code of zero on success, and a
+
//!     non-zero status code on failure.
+
//!  6. The helper may write to standard error for logging and debugging
+
//!     purposes.
+
//!
+
//! # Syntax of Operations
+
//!
+
//! The operations sent from Radicle to the helper are of the following shape:
+
//!
+
//! ```json
+
//! {
+
//!     "title": "Operation as sent to helper"
+
//!     "type": "object",
+
//!     "properties": {
+
//!         "concurrent": {
+
//!             "type": "array",
+
//!             "items": {
+
//!                 "$ref": "#/definitions/radicle::cob::Op"
+
//!             }
+
//!         },
+
//!         "value": {
+
//!             "type": "object",
+
//!             "properties": {
+
//!                 "prop": {
+
//!                     "$ref": "#/definitions/radicle::cob::external::External"
+
//!                 }
+
//!             }
+
//!         },
+
//!         "op": {
+
//!             "type": "object",
+
//!             "properties": {
+
//!                 "prop": {
+
//!                     "$ref": "#/definitions/radicle::cob::Op"
+
//!                 }
+
//!             }
+
//!         }
+
//!     },
+
//! }
+
//! ```
+
//!
+
//! [JSON Lines]: https://jsonlines.org/
+
//! [credential helpers]: https://git-scm.com/doc/credential-helpers
+
//! [remote helpers]: https://git-scm.com/docs/gitremote-helpers
+

+
use std::collections::HashMap;
+
use std::io::{Error as IoError, ErrorKind};
+
use std::process::{Command, Stdio};
+

+
use serde::{Deserialize, Serialize};
+
use thiserror::Error;
+

+
use serde_json::{from_slice, to_writer, Error as JsonError, Map, Value};
+

+
use crate::cob::object::collaboration::Evaluate;
+
use crate::cob::op::{Op as CobOp, OpEncodingError};
+
use crate::cob::store::{Cob, CobAction};
+
use crate::git::Oid;
+
use crate::storage::ReadRepository;
+

+
/// This prefix is used to generate the name of the command,
+
/// which is executed by the helper to apply operations.
+
static COB_EXTERNAL_COMMAND_PREFIX: &str = "rad-cob-";
+

+
#[derive(PartialEq, Debug, Serialize, Deserialize)]
+
pub struct External(Value);
+

+
impl Default for External {
+
    fn default() -> Self {
+
        Self(Value::Object(Map::default()))
+
    }
+
}
+

+
#[derive(Error, Debug)]
+
pub enum Error {
+
    #[error("op decoding failed: {0}")]
+
    Op(#[from] OpEncodingError),
+
    #[error("serde_json: {0}")]
+
    Serde(#[from] JsonError),
+
    #[error("io: {0}")]
+
    Io(#[from] IoError),
+
}
+

+
#[derive(Clone, Debug, Serialize, Deserialize)]
+
pub struct Action {
+
    #[serde(default)]
+
    parents: Vec<Oid>,
+

+
    #[serde(flatten)]
+
    map: HashMap<String, Value>,
+
}
+

+
impl CobAction for Action {
+
    fn parents(&self) -> Vec<Oid> {
+
        self.parents.clone()
+
    }
+
}
+

+
impl From<Action> for nonempty::NonEmpty<Action> {
+
    fn from(action: Action) -> Self {
+
        Self::new(action)
+
    }
+
}
+

+
pub type Op = CobOp<Action>;
+

+
impl External {
+
    fn handle<R: ReadRepository>(
+
        &mut self,
+
        op: Op,
+
        concurrent: Vec<Op>,
+
        _repo: &R,
+
    ) -> Result<(), Error> {
+
        let command_name = {
+
            let prefix = String::from(COB_EXTERNAL_COMMAND_PREFIX);
+
            let type_name = op.manifest.type_name.to_string();
+
            let suffix = type_name
+
                .rsplit_once('.')
+
                .map(|(_, suffix)| suffix)
+
                .unwrap_or(type_name.as_str());
+
            prefix + suffix
+
        };
+

+
        let child = Command::new(command_name)
+
            .stdin(Stdio::piped())
+
            .stdout(Stdio::piped())
+
            .spawn()?;
+

+
        let Some(stdin) = &child.stdin else {
+
            return Err(Error::Io(IoError::new(
+
                ErrorKind::BrokenPipe,
+
                "stdin not available",
+
            )));
+
        };
+

+
        #[derive(Serialize)]
+
        struct OpMessage {
+
            value: Value,
+
            op: Op,
+
            concurrent: Vec<Op>,
+
        }
+

+
        to_writer(
+
            stdin,
+
            &OpMessage {
+
                value: self.0.clone(),
+
                op,
+
                concurrent,
+
            },
+
        )?;
+

+
        self.0 = from_slice(&child.wait_with_output()?.stdout)?;
+
        Ok(())
+
    }
+
}
+

+
impl Cob for External {
+
    type Action = Action;
+
    type Error = Error;
+

+
    fn from_root<R: ReadRepository>(
+
        op: super::Op<Self::Action>,
+
        repo: &R,
+
    ) -> Result<Self, Self::Error> {
+
        let mut root = Self::default();
+
        root.handle(op, vec![], repo)?;
+
        Ok(root)
+
    }
+

+
    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a super::Entry>>(
+
        &mut self,
+
        op: super::Op<Self::Action>,
+
        concurrent: I,
+
        repo: &R,
+
    ) -> Result<(), <Self as Cob>::Error> {
+
        let concurrent: Vec<Op> = concurrent
+
            .into_iter()
+
            .map(Op::try_from)
+
            .collect::<Result<Vec<Op>, _>>()?;
+
        self.handle(op, concurrent, repo)
+
    }
+
}
+

+
impl<R: ReadRepository> Evaluate<R> for External {
+
    type Error = Error;
+

+
    fn init(entry: &radicle_cob::Entry, store: &R) -> Result<Self, Self::Error> {
+
        Self::from_root(Op::try_from(entry)?, store)
+
    }
+

+
    fn apply<'a, I: Iterator<Item = (&'a radicle_git_ext::Oid, &'a radicle_cob::Entry)>>(
+
        &mut self,
+
        entry: &radicle_cob::Entry,
+
        concurrent: I,
+
        repo: &R,
+
    ) -> Result<(), Self::Error> {
+
        let concurrent: Vec<Op> = concurrent
+
            .map(|(_, e)| e)
+
            .map(Op::try_from)
+
            .collect::<Result<Vec<Op>, _>>()?;
+
        self.handle(Op::try_from(entry)?, concurrent, repo)
+
    }
+
}
modified radicle/src/cob/identity.rs
@@ -77,7 +77,11 @@ pub enum Action {
    RevisionRedact { revision: RevisionId },
}

-
impl CobAction for Action {}
+
impl CobAction for Action {
+
    fn produces_identifier(&self) -> bool {
+
        matches!(self, Self::Revision { .. })
+
    }
+
}

/// Error applying an operation onto a state.
#[derive(Error, Debug)]
@@ -155,6 +159,12 @@ pub struct Identity {
    timeline: Vec<EntryId>,
}

+
impl cob::store::CobWithType for Identity {
+
    fn type_name() -> &'static TypeName {
+
        &TYPENAME
+
    }
+
}
+

impl std::ops::Deref for Identity {
    type Target = Revision;

@@ -206,6 +216,8 @@ impl Identity {
        object: &ObjectId,
        repo: &R,
    ) -> Result<Identity, store::Error> {
+
        use cob::store::CobWithType;
+

        cob::get::<Self, _>(repo, Self::type_name(), object)
            .map(|r| r.map(|cob| cob.object))?
            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object))
@@ -293,10 +305,6 @@ impl store::Cob for Identity {
    type Action = Action;
    type Error = ApplyError;

-
    fn type_name() -> &'static TypeName {
-
        &TYPENAME
-
    }
-

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
        let mut actions = op.actions.into_iter();
        let Some(Action::Revision {
modified radicle/src/cob/issue.rs
@@ -142,13 +142,15 @@ pub struct Issue {
    pub(super) thread: Thread,
}

-
impl store::Cob for Issue {
-
    type Action = Action;
-
    type Error = Error;
-

+
impl cob::store::CobWithType for Issue {
    fn type_name() -> &'static TypeName {
        &TYPENAME
    }
+
}
+

+
impl store::Cob for Issue {
+
    type Action = Action;
+
    type Error = Error;

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
@@ -933,7 +935,11 @@ pub enum Action {
    },
}

-
impl CobAction for Action {}
+
impl CobAction for Action {
+
    fn produces_identifier(&self) -> bool {
+
        matches!(self, Self::Comment { .. })
+
    }
+
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
@@ -941,11 +947,11 @@ mod test {
    use pretty_assertions::assert_eq;

    use super::*;
-
    use crate::cob::{ActorId, Reaction};
+
    use crate::cob::{store::CobWithType, ActorId, Reaction};
    use crate::git::Oid;
    use crate::issue::cache::Issues as _;
-
    use crate::test;
    use crate::test::arbitrary;
+
    use crate::{assert_matches, test};

    #[test]
    fn test_concurrency() {
@@ -1687,6 +1693,29 @@ mod test {
    }

    #[test]
+
    fn test_invalid_tx_reference() {
+
        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
+
        let mut issues = Cache::no_cache(&*repo).unwrap();
+
        let issue = issues
+
            .create(
+
                "My first issue",
+
                "Blah blah blah.",
+
                &[],
+
                &[],
+
                [],
+
                &node.signer,
+
            )
+
            .unwrap();
+

+
        // Comments require references, so adding two of them to the same transaction errors.
+
        let mut tx: Transaction<Issue, test::storage::git::Repository> =
+
            Transaction::<Issue, _>::default();
+
        tx.comment("First reply", *issue.id, vec![]).unwrap();
+
        let err = tx.comment("Second reply", *issue.id, vec![]).unwrap_err();
+
        assert_matches!(err, cob::store::Error::ClashingIdentifiers(_, _));
+
    }
+

+
    #[test]
    fn test_invalid_cob() {
        use crate::crypto::test::signer::MockSigner;
        use cob::change::Storage as _;
modified radicle/src/cob/job.rs
@@ -24,6 +24,8 @@ use crate::git;
use crate::prelude::ReadRepository;
use crate::storage::{Oid, WriteRepository};

+
use super::store::CobWithType;
+

/// The name of this COB type. Note that this is a "beta" COB, which
/// means it's not meant for others to rely on yet, and we may change
/// it without warning.
@@ -213,10 +215,6 @@ impl Cob for Job {
    type Action = Action;
    type Error = Error;

-
    fn type_name() -> &'static TypeName {
-
        &TYPENAME
-
    }
-

    fn from_root<R: ReadRepository>(op: Op, _repo: &R) -> Result<Self, Self::Error> {
        let mut actions = op.actions.into_iter();
        let Some(Action::Trigger { commit }) = actions.next() else {
@@ -245,6 +243,12 @@ impl Cob for Job {
    }
}

+
impl CobWithType for Job {
+
    fn type_name() -> &'static TypeName {
+
        &TYPENAME
+
    }
+
}
+

impl<R: ReadRepository> cob::Evaluate<R> for Job {
    type Error = Error;

modified radicle/src/cob/patch.rs
@@ -91,7 +91,7 @@ pub enum Error {
    /// This error indicates that the operations are not being applied
    /// in causal order, which is a requirement for this CRDT.
    ///
-
    /// For example, this can occur if an operation references anothern operation
+
    /// For example, this can occur if an operation references another operation
    /// that hasn't happened yet.
    #[error("causal dependency {0:?} missing")]
    Missing(EntryId),
@@ -320,6 +320,16 @@ impl CobAction for Action {
            _ => vec![],
        }
    }
+

+
    fn produces_identifier(&self) -> bool {
+
        matches!(
+
            self,
+
            Self::Revision { .. }
+
                | Self::RevisionComment { .. }
+
                | Self::Review { .. }
+
                | Self::ReviewComment { .. }
+
        )
+
    }
}

/// Output of a merge.
@@ -1156,13 +1166,15 @@ impl Patch {
    }
}

-
impl store::Cob for Patch {
-
    type Action = Action;
-
    type Error = Error;
-

+
impl cob::store::CobWithType for Patch {
    fn type_name() -> &'static TypeName {
        &TYPENAME
    }
+
}
+

+
impl store::Cob for Patch {
+
    type Action = Action;
+
    type Error = Error;

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
modified radicle/src/cob/store.rs
@@ -22,6 +22,23 @@ pub trait CobAction: Debug {
    fn parents(&self) -> Vec<git::Oid> {
        Vec::new()
    }
+

+
    /// The outcome of some actions are to be referred later.
+
    /// For example, one action may create a comment, followed by another
+
    /// action that may create a reply to the comment, referring to it.
+
    /// Since actions are stored as part of [`crate::cob::op::Op`],
+
    /// and operations are the smallest identifiable units,
+
    /// this may lead to ambiguity.
+
    /// It would not be possible to to, say, address one particular comment out
+
    /// of two, if the corresponding actions of creations were part of the
+
    /// same operation.
+
    /// To help avoid this, implementations signal whether specific actions
+
    /// require "their own" identifier.
+
    /// This allows checking for multiple such actions before creating an
+
    /// operation.
+
    fn produces_identifier(&self) -> bool {
+
        false
+
    }
}

/// A collaborative object. Can be materialized from an operation history.
@@ -31,9 +48,6 @@ pub trait Cob: Sized + PartialEq + Debug {
    /// Error returned by `apply` function.
    type Error: std::error::Error + Send + Sync + 'static;

-
    /// The object type name.
-
    fn type_name() -> &'static TypeName;
-

    /// Initialize a collarorative object from a root operation.
    fn from_root<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error>;

@@ -50,7 +64,10 @@ pub trait Cob: Sized + PartialEq + Debug {
    fn from_history<R: ReadRepository>(
        history: &crate::cob::History,
        repo: &R,
-
    ) -> Result<Self, test::HistoryError<Self>> {
+
    ) -> Result<Self, test::HistoryError<Self>>
+
    where
+
        Self: CobWithType,
+
    {
        test::from_history::<R, Self>(history, repo)
    }

@@ -73,6 +90,15 @@ pub trait Cob: Sized + PartialEq + Debug {
    }
}

+
/// Implementations are statically associated with a particular
+
/// type name of a collaborative object.
+
///
+
/// In most cases, this trait should be used in tandem with [`Cob`].
+
pub trait CobWithType {
+
    /// The type name of the collaborative object type which backs this implementation.
+
    fn type_name() -> &'static TypeName;
+
}
+

/// Store error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -102,6 +128,8 @@ pub enum Error {
        #[source]
        err: git::raw::Error,
    },
+
    #[error("transaction already contains action {0} which produces an identifier, denying to add action {1} which also produces an identifier")]
+
    ClashingIdentifiers(String, String),
}

/// Storage for collaborative objects of a specific type `T` in a single repository.
@@ -109,6 +137,7 @@ pub struct Store<'a, T, R> {
    identity: Option<git::Oid>,
    repo: &'a R,
    witness: PhantomData<T>,
+
    type_name: &'a TypeName,
}

impl<T, R> AsRef<R> for Store<'_, T, R> {
@@ -122,11 +151,12 @@ where
    R: ReadRepository + cob::Store,
{
    /// Open a new generic store.
-
    pub fn open(repo: &'a R) -> Result<Self, Error> {
+
    pub fn open_for(type_name: &'a TypeName, repo: &'a R) -> Result<Self, Error> {
        Ok(Self {
            repo,
            identity: None,
            witness: PhantomData,
+
            type_name,
        })
    }

@@ -136,10 +166,41 @@ where
            repo: self.repo,
            witness: self.witness,
            identity: Some(identity),
+
            type_name: self.type_name,
        }
    }
}

+
impl<'a, T, R> Store<'a, T, R>
+
where
+
    R: ReadRepository + cob::Store,
+
    T: CobWithType,
+
{
+
    /// Open a new generic store.
+
    pub fn open(repo: &'a R) -> Result<Self, Error> {
+
        Ok(Self {
+
            repo,
+
            identity: None,
+
            witness: PhantomData,
+
            type_name: T::type_name(),
+
        })
+
    }
+
}
+

+
impl<T, R> Store<'_, T, R>
+
where
+
    R: ReadRepository + cob::Store,
+
    T: Cob + cob::Evaluate<R>,
+
{
+
    pub fn transaction(
+
        &self,
+
        actions: Vec<T::Action>,
+
        embeds: Vec<Embed<Uri>>,
+
    ) -> Transaction<T, R> {
+
        Transaction::new(self.type_name.clone(), actions, embeds)
+
    }
+
}
+

impl<T, R> Store<'_, T, R>
where
    R: ReadRepository + SignRepository + cob::Store,
@@ -149,6 +210,7 @@ where
    /// Update an object.
    pub fn update<G: Signer>(
        &self,
+
        type_name: &TypeName,
        object_id: ObjectId,
        message: &str,
        actions: impl Into<NonEmpty<T::Action>>,
@@ -175,7 +237,7 @@ where
            signer.public_key(),
            Update {
                object_id,
-
                type_name: T::type_name().clone(),
+
                type_name: type_name.clone(),
                message: message.to_owned(),
                embeds,
                changes,
@@ -215,7 +277,7 @@ where
            parents,
            signer.public_key(),
            Create {
-
                type_name: T::type_name().clone(),
+
                type_name: self.type_name.clone(),
                version: Version::default(),
                message: message.to_owned(),
                embeds,
@@ -225,7 +287,7 @@ where
        // Nb. We can't sign our refs before the identity refs exist, which are created after
        // the identity COB is created. Therefore we manually sign refs when creating identity
        // COBs.
-
        if T::type_name() != &*crate::cob::identity::TYPENAME {
+
        if self.type_name != &*crate::cob::identity::TYPENAME {
            self.repo
                .sign_refs(signer)
                .map_err(|e| Error::SignRefs(Box::new(e)))?;
@@ -235,13 +297,13 @@ where

    /// Remove an object.
    pub fn remove<G: Signer>(&self, id: &ObjectId, signer: &G) -> Result<(), Error> {
-
        let name = git::refs::storage::cob(signer.public_key(), T::type_name(), id);
+
        let name = git::refs::storage::cob(signer.public_key(), self.type_name, id);
        match self
            .repo
            .reference_oid(signer.public_key(), &name.strip_namespace())
        {
            Ok(_) => {
-
                cob::remove(self.repo, signer.public_key(), T::type_name(), id)?;
+
                cob::remove(self.repo, signer.public_key(), self.type_name, id)?;
                self.repo
                    .sign_refs(signer)
                    .map_err(|e| Error::SignRefs(Box::new(e)))?;
@@ -259,12 +321,12 @@ where
impl<'a, T, R> Store<'a, T, R>
where
    R: ReadRepository + cob::Store,
-
    T: cob::Evaluate<R> + Cob,
+
    T: Cob + cob::Evaluate<R>,
    T::Action: Serialize,
{
    /// Get an object.
    pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
-
        cob::get::<T, _>(self.repo, T::type_name(), id)
+
        cob::get::<T, _>(self.repo, self.type_name, id)
            .map(|r| r.map(|cob| cob.object))
            .map_err(Error::from)
    }
@@ -273,7 +335,7 @@ where
    pub fn all(
        &self,
    ) -> Result<impl ExactSizeIterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
-
        let raw = cob::list::<T, _>(self.repo, T::type_name())?;
+
        let raw = cob::list::<T, _>(self.repo, self.type_name)?;

        Ok(raw.into_iter().map(|o| Ok((*o.id(), o.object))))
    }
@@ -285,7 +347,7 @@ where

    /// Return objects count.
    pub fn count(&self) -> Result<usize, Error> {
-
        let raw = cob::list::<T, _>(self.repo, T::type_name())?;
+
        let raw = cob::list::<T, _>(self.repo, self.type_name)?;

        Ok(raw.len())
    }
@@ -296,15 +358,26 @@ where
pub struct Transaction<T: Cob + cob::Evaluate<R>, R> {
    actions: Vec<T::Action>,
    embeds: Vec<Embed<Uri>>,
+

+
    // Internal state kept for validation of the transaction.
+
    // If an action that produces an identifier is added to
+
    // the transaction, then this will track its index,
+
    // so that adding a second action that produces an identifier
+
    // can fail with a useful error.
+
    produces_identifier: Option<usize>,
+

    repo: PhantomData<R>,
+
    type_name: TypeName,
}

-
impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
+
impl<T: Cob + CobWithType + cob::Evaluate<R>, R> Default for Transaction<T, R> {
    fn default() -> Self {
        Self {
            actions: Vec::new(),
            embeds: Vec::new(),
+
            produces_identifier: None,
            repo: PhantomData,
+
            type_name: T::type_name().clone(),
        }
    }
}
@@ -313,6 +386,21 @@ impl<T, R> Transaction<T, R>
where
    T: Cob + cob::Evaluate<R>,
{
+
    pub fn new(type_name: TypeName, actions: Vec<T::Action>, embeds: Vec<Embed<Uri>>) -> Self {
+
        Self {
+
            actions,
+
            embeds,
+
            produces_identifier: None,
+
            repo: PhantomData,
+
            type_name,
+
        }
+
    }
+
}
+

+
impl<T, R> Transaction<T, R>
+
where
+
    T: Cob + CobWithType + cob::Evaluate<R>,
+
{
    /// Create a new transaction to be used as the initial set of operations for a COB.
    pub fn initial<G, F, Tx>(
        message: &str,
@@ -337,14 +425,40 @@ where

        store.create(message, actions, tx.embeds, signer)
    }
+
}

-
    /// Add an operation to this transaction.
+
impl<T, R> Transaction<T, R>
+
where
+
    T: Cob + cob::Evaluate<R>,
+
{
+
    /// Add an action to this transaction.
    pub fn push(&mut self, action: T::Action) -> Result<(), Error> {
+
        if action.produces_identifier() {
+
            if let Some(index) = self.produces_identifier {
+
                return Err(Error::ClashingIdentifiers(
+
                    serde_json::to_string(&self.actions[index])?,
+
                    serde_json::to_string(&action)?,
+
                ));
+
            } else {
+
                self.produces_identifier = Some(self.actions.len())
+
            }
+
        }
+

        self.actions.push(action);

        Ok(())
    }

+
    /// Add actions to this transaction.
+
    /// Note that we cannot implement [`std::iter::Extend`] because [`Self::push`]
+
    /// validates the action being pushed, and therefore is falliable.
+
    pub fn extend<I: IntoIterator<Item = T::Action>>(&mut self, actions: I) -> Result<(), Error> {
+
        for action in actions {
+
            self.push(action)?;
+
        }
+
        Ok(())
+
    }
+

    /// Embed media into the transaction.
    pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed<Uri>>) -> Result<(), Error> {
        self.embeds.extend(embeds);
@@ -372,7 +486,7 @@ where
            head,
            object: CollaborativeObject { object, .. },
            ..
-
        } = store.update(id, msg, actions, self.embeds, signer)?;
+
        } = store.update(&self.type_name, id, msg, actions, self.embeds, signer)?;

        Ok((object, head))
    }
@@ -424,7 +538,7 @@ pub mod test {

    /// Turn a history into a concrete type, by traversing the history and applying each operation
    /// to the state, skipping branches that return errors.
-
    pub fn from_history<R: ReadRepository, T: Cob>(
+
    pub fn from_history<R: ReadRepository, T: Cob + CobWithType>(
        history: &crate::cob::History,
        repo: &R,
    ) -> Result<T, HistoryError<T>> {
modified radicle/src/cob/test.rs
@@ -22,7 +22,7 @@ use crate::profile::env;
use crate::storage::ReadRepository;
use crate::test::arbitrary;

-
use super::store::Cob;
+
use super::store::{Cob, CobWithType};
use super::thread;

/// Convenience type for building histories.
@@ -57,6 +57,7 @@ impl HistoryBuilder<thread::Thread> {

impl<T: Cob> HistoryBuilder<T>
where
+
    T: CobWithType,
    T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
{
    pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
@@ -133,12 +134,13 @@ impl<A> Deref for HistoryBuilder<A> {
}

/// Create a new test history.
-
pub fn history<T: Cob, G: Signer>(
+
pub fn history<T, G: Signer>(
    actions: &[T::Action],
    time: Timestamp,
    signer: &G,
) -> HistoryBuilder<T>
where
+
    T: Cob + CobWithType,
    T::Action: Serialize + Eq + 'static,
{
    HistoryBuilder::new(actions, time, signer)
@@ -163,13 +165,14 @@ impl<G> Actor<G> {

impl<G: Signer> Actor<G> {
    /// Create a new operation.
-
    pub fn op_with<T: Cob>(
+
    pub fn op_with<T>(
        &mut self,
        actions: impl IntoIterator<Item = T::Action>,
        identity: Option<Oid>,
        timestamp: Timestamp,
    ) -> Op<T::Action>
    where
+
        T: Cob + CobWithType,
        T::Action: Clone + Serialize,
    {
        let actions = actions.into_iter().collect::<Vec<_>>();
@@ -199,8 +202,9 @@ impl<G: Signer> Actor<G> {
    }

    /// Create a new operation.
-
    pub fn op<T: Cob>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
+
    pub fn op<T>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
    where
+
        T: Cob + CobWithType,
        T::Action: Clone + Serialize,
    {
        let identity = arbitrary::oid();
modified radicle/src/cob/thread.rs
@@ -272,7 +272,11 @@ pub enum Action {
    },
}

-
impl cob::store::CobAction for Action {}
+
impl cob::store::CobAction for Action {
+
    fn produces_identifier(&self) -> bool {
+
        matches!(self, Self::Comment { .. })
+
    }
+
}

impl From<Action> for nonempty::NonEmpty<Action> {
    fn from(action: Action) -> Self {
@@ -299,6 +303,12 @@ impl<T> Default for Thread<T> {
    }
}

+
impl<T> cob::store::CobWithType for Thread<T> {
+
    fn type_name() -> &'static radicle_cob::TypeName {
+
        &TYPENAME
+
    }
+
}
+

impl<T> Thread<T> {
    pub fn new(id: CommentId, comment: T) -> Self {
        Self {
@@ -403,10 +413,6 @@ impl cob::store::Cob for Thread {
    type Action = Action;
    type Error = Error;

-
    fn type_name() -> &'static radicle_cob::TypeName {
-
        &TYPENAME
-
    }
-

    fn from_root<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
        let author = op.author;
        let entry = op.id;
modified radicle/src/identity.rs
@@ -8,4 +8,4 @@ pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};
pub use project::Project;

-
pub use crate::cob::identity::{Error, Identity, IdentityMut};
+
pub use crate::cob::identity::{Action, Error, Identity, IdentityMut, TYPENAME};
modified radicle/src/lib.rs
@@ -31,7 +31,7 @@ pub mod test;
pub mod version;
pub mod web;

-
pub use cob::{issue, patch};
+
pub use cob::{external, issue, patch};
pub use node::Node;
pub use profile::Profile;
pub use storage::git::Storage;