Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Introduce `xyz.radicle.id` COB
cloudhead committed 2 years ago
commit 1d167581f2fa2e8ef0e36a268db1affef7418c64
parent c99d9a6aed7546d54534ccb53f198dd5672b63a9
99 files changed +3061 -3570
modified Cargo.lock
@@ -1824,7 +1824,6 @@ dependencies = [
 "serde",
 "serde_json",
 "serde_yaml",
-
 "similar",
 "tempfile",
 "thiserror",
 "timeago",
modified radicle-cli/Cargo.toml
@@ -26,7 +26,7 @@ radicle-surf = { version = "0.17.0" }
serde = { version = "1.0" }
serde_json = { version = "1" }
serde_yaml = { version = "0.8" }
-
similar = { version = "2.2.1" }
+
tempfile = { version = "3.3.0" }
thiserror = { version = "1" }
timeago = { version = "0.3", default-features = false }
tree-sitter = { version = "0.20.0" }
@@ -73,4 +73,3 @@ path = "../radicle-term"
pretty_assertions = { version = "1.3.0" }
radicle = { path = "../radicle", features = ["test"] }
radicle-node = { path = "../radicle-node", features = ["test"] }
-
tempfile = { version = "3.3.0" }
modified radicle-cli/examples/git/git-push-diverge.md
@@ -5,9 +5,8 @@ canonical head.
First we add a second delegate, Bob, to our repo:

``` ~alice
-
$ rad delegate add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
Added delegate 'did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk'
-
✓ Update successful!
+
$ rad id update --title "Add Bob" --description "" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji -q
+
60cbee1bbc49fcbb8063249bc4112c8886a756ba
$ rad remote add did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
✓ Remote bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk added
✓ Remote-tracking branch bob@z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk/master created for z6Mkt67…v4N1tRk
modified radicle-cli/examples/rad-clone-all.md
@@ -39,6 +39,9 @@ Let's check that we have all the namespaces in storage:
$ rad inspect --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
+
    ├── cobs
+
    │   └── xyz.radicle.id
+
    │       └── [...]
    ├── heads
    │   └── master
    └── rad
@@ -49,14 +52,12 @@ z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
    ├── heads
    │   └── master
    └── rad
-
        ├── id
        └── sigrefs
z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
└── refs
    ├── heads
    │   └── master
    └── rad
-
        ├── id
        └── sigrefs
```

modified radicle-cli/examples/rad-cob.md
@@ -6,7 +6,7 @@ First create an issue.
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Status  open                                            │
│                                                         │
@@ -21,7 +21,7 @@ $ rad issue list
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●   ID        Title                         Author                    Labels   Assignees   Opened       │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   42028af   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                        [    ..    ] │
+
│ ●   9bf82c1   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                        [    ..    ] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -45,7 +45,7 @@ $ rad patch
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author                  Head     +   -   Updated      │
├──────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  73b73f3  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
+
│ ●  a892664  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -53,17 +53,17 @@ Both issue and patch COBs can be listed.

```
$ rad cob list --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue
-
42028af21fabc09bfac2f25490f119f7c7e11542
+
9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d
$ rad cob list --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch
-
73b73f376e93e09e0419664766ac9e433bf7d389
+
a8926643a8f6a65bc386b0131621994000485d4d
```

We can look at the issue COB.

```
-
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object 42028af21fabc09bfac2f25490f119f7c7e11542
-
commit 42028af21fabc09bfac2f25490f119f7c7e11542
-
parent 175267b8910895ba87760313af254c2900743912
+
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.issue --object 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d
+
commit 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d
+
parent 2317f74de0494c489a233ca6f29f2b8bff6d4f15
author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
date   Thu, 15 Dec 2022 17:28:04 +0000

@@ -92,9 +92,9 @@ date Thu, 15 Dec 2022 17:28:04 +0000
We can look at the patch COB too.

```
-
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 73b73f376e93e09e0419664766ac9e433bf7d389
-
commit 73b73f376e93e09e0419664766ac9e433bf7d389
-
parent 175267b8910895ba87760313af254c2900743912
+
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object a8926643a8f6a65bc386b0131621994000485d4d
+
commit a8926643a8f6a65bc386b0131621994000485d4d
+
parent 2317f74de0494c489a233ca6f29f2b8bff6d4f15
parent 3e674d1a1df90807e934f9ae5da2591dd6848a33
parent f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
deleted radicle-cli/examples/rad-delegate.md
@@ -1,46 +0,0 @@
-
Delegates are the authorized keys that can manage a project's
-
metadata, including adding a new delegate.
-

-
Let's list the current set of delegates for a project.
-

-
```
-
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
[
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-
```
-

-
We want to add a new maintainer to the project to help out with the
-
work.
-

-
```
-
$ rad delegate add did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'
-
✓ Update successful!
-
```
-

-
Let's convince ourselves that there's another delegate.
-

-
```
-
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
[
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
  "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
-
]
-
```
-

-
And finally, we no longer want to be part of the project so we pass on
-
the torch and remove ourselves from the delegate set.
-

-
```
-
$ rad delegate remove did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
-
✓ Update successful!
-
```
-

-
```
-
$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
[
-
  "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
-
]
-
```
modified radicle-cli/examples/rad-fork.md
@@ -7,6 +7,9 @@ NID. This is demonstrated below where our NID is
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
+
    ├── cobs
+
    │   └── xyz.radicle.id
+
    │       └── [...]
    ├── heads
    │   └── master
    └── rad
@@ -29,6 +32,9 @@ have a copy of the main set of refs:
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
+
    ├── cobs
+
    │   └── xyz.radicle.id
+
    │       └── [...]
    ├── heads
    │   └── master
    └── rad
@@ -39,7 +45,6 @@ z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
    ├── heads
    │   └── master
    └── rad
-
        ├── id
        └── sigrefs
```

added radicle-cli/examples/rad-id-conflict.md
@@ -0,0 +1,104 @@
+
First let's add Bob as a delegate, and sync the changes to Bob:
+

+
``` ~alice
+
$ rad id update --title "Add Bob" --description "Add Bob as a delegate" --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --threshold 2 -q
+
bd41a1cc152a7cb8b9fb84261e4214ffa4cdb7a4
+
```
+
``` ~bob
+
$ cd heartwood
+
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
+
✓ Fetched repository from 1 seed(s)
+
```
+

+
One thing that can happen is that two delegates propose a revision at the same
+
time:
+

+
``` ~alice
+
$ rad id update --title "Edit project name" --description "" --payload "xyz.radicle.project" "name" '"heart"' -q
+
6c07e4e604d855f6730f884dc56216c5698ef7f8
+
```
+
``` ~bob
+
$ rad id update --title "Edit project name" --description "" --payload "xyz.radicle.project" "name" '"wood"' -q
+
fae22d07f7d386b89f14ac353b079c9eef71f948
+
```
+

+
When Alice syncs with Bob, she notices the problem: there are two active
+
revisions.
+

+
``` ~alice
+
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkt67…v4N1tRk..
+
✓ Fetched repository from 1 seed(s)
+
$ rad id list
+
╭──────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title               Author                     Status     Created      │
+
├──────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   fae22d0   Edit project name   bob      z6Mkt67…v4N1tRk   active     [   ...    ] │
+
│ ●   6c07e4e   Edit project name   alice    (you)             active     [   ...    ] │
+
│ ●   bd41a1c   Add Bob             alice    (you)             accepted   [   ...    ] │
+
│ ●   2317f74   Initial revision    alice    (you)             accepted   [   ...    ] │
+
╰──────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
This isn't a problem as long as we don't try to accept both. So let's accept
+
Bob's:
+

+
``` ~alice
+
$ rad id accept fae22d0 -q
+
$ rad id list
+
╭──────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title               Author                     Status     Created      │
+
├──────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   fae22d0   Edit project name   bob      z6Mkt67…v4N1tRk   accepted   [   ...    ] │
+
│ ●   6c07e4e   Edit project name   alice    (you)             stale      [   ...    ] │
+
│ ●   bd41a1c   Add Bob             alice    (you)             accepted   [   ...    ] │
+
│ ●   2317f74   Initial revision    alice    (you)             accepted   [   ...    ] │
+
╰──────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Doing so voided the other conflicting revision, and it can no longer be
+
accepted now.
+

+
``` ~bob
+
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
+
✓ Fetched repository from 1 seed(s)
+
```
+
``` ~bob (fail)
+
$ rad id accept 6c07e4e -q
+
✗ Error: cannot vote on revision that is stale
+
$ rad id reject 6c07e4e -q
+
✗ Error: cannot vote on revision that is stale
+
```
+
``` ~bob
+
$ rad id show 6c07e4e
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Edit project name                                             │
+
│ Revision 6c07e4e604d855f6730f884dc56216c5698ef7f8                      │
+
│ Blob     e93aa3e3c5c448bacd3537a81daf1437eccd046a                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    stale                                                         │
+
│ Quorum   no                                                            │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice       │
+
│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob   (you) │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,14 +1,14 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
-      "name": "heartwood"
+
+      "name": "heart"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
     "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
   ],
+
   "threshold": 2
+
 }
+
```
added radicle-cli/examples/rad-id-multi-delegate.md
@@ -0,0 +1,186 @@
+
``` ~alice
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add Bob" --description "" --threshold 2 --delegate did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm -q
+
5666f744e2bc2333cab30ea0256bc4b61c3205bf
+
```
+

+
``` ~bob
+
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSL…StBU8Vi..
+
✓ Fetched repository from 1 seed(s)
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Add Eve" --description "" --delegate did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
+
✓ Identity revision 9d9031417f1d86a6c0ed5ec2c4bf5820dca0eec9 created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add Eve                                                       │
+
│ Revision 9d9031417f1d86a6c0ed5ec2c4bf5820dca0eec9                      │
+
│ Blob     4c7fd4c7b7d7fd5d7088a7c952556fab99a034e9                      │
+
│ Author   did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk      │
+
│ State    active                                                        │
+
│ Quorum   no                                                            │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob   (you) │
+
│ ? did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice       │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,14 +1,15 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
-    "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
+    "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk",
+
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
+
   ],
+
   "threshold": 2
+
 }
+
```
+

+
``` ~alice
+
$ rad sync --fetch rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
✓ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6Mkt67…v4N1tRk..
+
✓ Fetched repository from 1 seed(s)
+
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --delegates
+
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
+
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
+
$ rad id accept 9d9031417f1d86a6c0ed5ec2c4bf5820dca0eec9 --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-confirm
+
✓ Revision 9d9031417f1d86a6c0ed5ec2c4bf5820dca0eec9 accepted
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add Eve                                                       │
+
│ Revision 9d9031417f1d86a6c0ed5ec2c4bf5820dca0eec9                      │
+
│ Blob     4c7fd4c7b7d7fd5d7088a7c952556fab99a034e9                      │
+
│ Author   did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk      │
+
│ State    accepted                                                      │
+
│ Quorum   yes                                                           │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
│ ✓ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob         │
+
╰────────────────────────────────────────────────────────────────────────╯
+
$ rad inspect rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --delegates
+
did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi (alice)
+
did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk (bob)
+
did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn
+
```
+

+
``` ~alice
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Make private" --description "" --visibility private --no-confirm -q
+
1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
```
+

+
We can list all revisions:
+

+
``` ~alice
+
$ rad id list
+
╭─────────────────────────────────────────────────────────────────────────────────────╮
+
│ ●   ID        Title              Author                     Status     Created      │
+
├─────────────────────────────────────────────────────────────────────────────────────┤
+
│ ●   1483814   Make private       alice    (you)             active     [   ...    ] │
+
│ ●   9d90314   Add Eve            bob      z6Mkt67…v4N1tRk   accepted   [   ...    ] │
+
│ ●   5666f74   Add Bob            alice    (you)             accepted   [   ...    ] │
+
│ ●   2317f74   Initial revision   alice    (you)             accepted   [   ...    ] │
+
╰─────────────────────────────────────────────────────────────────────────────────────╯
+
```
+

+
Despite being a delegate, Bob can't edit or redact Alice's revision:
+

+
``` ~bob (fail)
+
$ rad id redact 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
[..]
+
```
+
``` ~bob (fail)
+
$ rad id edit --title "Boo!" --description "Boo!" 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
[..]
+
```
+

+
Alice can edit:
+

+
``` ~alice
+
$ rad id edit --title "Make private" --description "Privacy is cool." 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
✓ Revision 1483814d54ad1321fc4ddb1f8bf7d90454de1790 edited
+
$ rad id show 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Make private                                                  │
+
│ Revision 1483814d54ad1321fc4ddb1f8bf7d90454de1790                      │
+
│ Blob     79bc5c39103e811a3c9f11744f9a4029f063a5de                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    active                                                        │
+
│ Quorum   no                                                            │
+
│                                                                        │
+
│ Privacy is cool.                                                       │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob         │
+
│ ? did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn             │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,15 +1,18 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
     "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk",
+
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
+
   ],
+
-  "threshold": 2
+
+  "threshold": 2,
+
+  "visibility": {
+
+    "type": "private"
+
+  }
+
 }
+
```
+

+
And she can redact her revision:
+

+
``` ~alice
+
$ rad id redact 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
✓ Revision 1483814d54ad1321fc4ddb1f8bf7d90454de1790 redacted
+
```
+
``` ~alice (fail)
+
$ rad id show 1483814d54ad1321fc4ddb1f8bf7d90454de1790
+
✗ Error: revision `1483814d54ad1321fc4ddb1f8bf7d90454de1790` not found
+
```
+

+
Finally, Alice can also propose to remove Bob:
+
``` ~alice
+
$ rad id update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --title "Remove Bob" --description "" --rescind did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk --no-confirm
+
✓ Identity revision ea60049b8265f60f3dcca21798ce50ef67779421 created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Remove Bob                                                    │
+
│ Revision ea60049b8265f60f3dcca21798ce50ef67779421                      │
+
│ Blob     7109c1c201c223dd4e9fdb10f7330dc6f0310258                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    active                                                        │
+
│ Quorum   no                                                            │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob         │
+
│ ? did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn             │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,15 +1,14 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
-    "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk",
+
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
+
   ],
+
   "threshold": 2
+
 }
+
```
deleted radicle-cli/examples/rad-id-rebase.md
@@ -1,429 +0,0 @@
-
In this example, we're going to see what happens when a proposal
-
drifts away from the latest Radicle identity.
-

-
First off, we will create two proposals -- we can imagine two
-
delegates creating proposals concurrently.
-

-
```
-
$ rad id edit --title "Add Alice" --description "Add Alice as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '6b73fce909f612d5d92084b91309d73c21fea396' created
-
title: Add Alice
-
description: Add Alice as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
```
-
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --no-confirm
-
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' created
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
Now, if the first proposal was accepted and committed before the
-
second proposal, then the identity would be out of date. So let's run
-
through that and see what happens.
-

-
```
-
$ rad id accept 6b73fce909f612d5d92084b91309d73c21fea396 --no-confirm
-
✓ Accepted proposal ✓
-
title: Add Alice
-
description: Add Alice as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
-

-
```
-
$ rad id commit 6b73fce909f612d5d92084b91309d73c21fea396 --no-confirm
-
✓ Committed new identity '29ae4b72f5a315328f06fbd68dc1c396a2d5c45e'
-
title: Add Alice
-
description: Add Alice as a delegate
-
status: ❲committed❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
-

-
Now, when we go to accept the second proposal:
-

-
```
-
$ rad id accept 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
-
! Warning: Revision is out of date
-
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
-
* Consider using 'rad id rebase' to update the proposal to the latest identity
-
✓ Accepted proposal ✓
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
-    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
-

-
Note that a warning was emitted:
-

-
    ! Warning: Revision is out of date
-
    ! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
-
    * Consider using 'rad id rebase' to update the proposal to the latest identity
-

-
If we attempt to commit this revision, the command will fail:
-

-
``` (fail)
-
$ rad id commit 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
-
! Warning: Revision is out of date
-
! Warning: d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f
-
* Consider using 'rad id rebase' to update the proposal to the latest identity
-
✗ Error: the identity hashes do not match for revision 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf (d96f425412c9f8ad5d9a9a05c9831d0728e2338d =/= 475cdfbc8662853dd132ec564e4f5eb0f152dd7f)
-
```
-

-
So, let's fix this by running a rebase on the proposal's revision:
-

-
```
-
$ rad id rebase 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --no-confirm
-
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' rebased
-
✓ Revision 'e56b3e0842a4dd37c2a997344bcb4113704e4768'
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
-    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
We can now update the proposal to have both keys in the delegates set:
-

-
```
-
$ rad id update 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev e56b3e0842a4dd37c2a997344bcb4113704e4768 --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf' updated
-
✓ Revision 'f15fc641d777cfb005caaa9405e262e516b8ac60'
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
Finally, we can accept and commit this proposal, creating the final
-
state of our new Radicle identity:
-

-
$ rad id show 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --revisions
-

-
```
-
$ rad id accept 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev f15fc641d777cfb005caaa9405e262e516b8ac60 --no-confirm
-
✓ Accepted proposal ✓
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
-

-
```
-
$ rad id commit 2bf3a85e209d10b11a65e7ed8a8085f6f18ac6bf --rev f15fc641d777cfb005caaa9405e262e516b8ac60 --no-confirm
-
✓ Committed new identity '60de897bc24898f6908fd1272633c0b15aa4096f'
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲committed❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
modified radicle-cli/examples/rad-id.md
@@ -4,24 +4,32 @@ maintainer. This requires adding them as a `delegate` and possibly
editing the `threshold` for passing new changes to the identity of the
project.

-
For cases where `threshold = 1`, it is enough to use the `rad
-
delegate` command. For cases where `threshold > 1`, it is necessary to
-
gather a quorum of signatures to update the Radicle identity. To do
-
this, we use the `rad id` command.
-

-
Let's add Bob as a delegate using their DID
-
`did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn`.
-

-
```
-
$ rad id edit --title "Add Bob" --description "Add Bob as a delegate" --delegates did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --no-confirm
-
✓ Identity proposal '662a8065f18db50d9ee952bb36eda5b605f161e9' created
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

+
For cases where `threshold > 1`, it is necessary to gather a quorum of
+
signatures to update the Radicle identity. To do this, we use the `rad id`
+
command. For now, since we are the only delegate, and `treshold` is `1`, we
+
can update the identity ourselves.
+

+
Let's add Bob as a delegate using their DID,
+
`did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn`, and update the
+
threshold to `2`.
+

+
```
+
$ rad id update --title "Add Bob" --description "Add Bob as a delegate" --delegate did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn --threshold 2
+
✓ Identity revision d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f created
+
╭───────────────────────────────────────────────────────────────────╮
+
│ Title    Add Bob                                                  │
+
│ Revision d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f                 │
+
│ Blob     7109c1c201c223dd4e9fdb10f7330dc6f0310258                 │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
+
│ State    accepted                                                 │
+
│ Quorum   yes                                                      │
+
│                                                                   │
+
│ Add Bob as a delegate                                             │
+
├───────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi  (you) │
+
╰───────────────────────────────────────────────────────────────────╯
+

+
@@ -1,13 +1,14 @@
 {
   "payload": {
     "xyz.radicle.project": {
@@ -35,367 +43,97 @@ Document Diff
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
   ],
-
   "threshold": 1
+
-  "threshold": 1
+
+  "threshold": 2
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
```

Before moving on, let's take a few notes on this output. The first
thing we'll notice is that the difference between the current identity
document and the proposed changes are shown. Specifically, we changed
-
the delegates:
-

-
    "delegates": [
-
    -    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
    +    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
    +    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
    ],
-

-
Next we have the number of `Accepted` reviews from delegates, starting
-
off with none:
-

-
    Accepted
-

-
    total: 0
-
    keys: []
+
the delegates and threshold:

-
The same with `Rejected` reviews:
+
      "delegates": [
+
    -   "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
    +   "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
    +   "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
+
      ],
+
    ...
+
    -  "threshold": 1
+
    +  "threshold": 2

-
    Rejected
+
Next we have the number of signatures from delegates, which includes our own:

-
    total: 0
-
    keys: []
+
    ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi

Finally, we can see whether the `Quorum` was reached:

-
    Quorum Reached
+
    Quorum   yes

-
    👎 no
-

-
Let's see what happens when we reject the change:
+
Since the threshold was previously `1`, this change is now in effect. We
+
can verify that by listing the current identity document:

```
-
$ rad id reject 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
-
✓ Rejected proposal 👎
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Quorum Reached
-

-
👎 no
+
$ rad inspect --identity
+
{
+
  "payload": {
+
    "xyz.radicle.project": {
+
      "defaultBranch": "master",
+
      "description": "Radicle Heartwood Protocol & Stack",
+
      "name": "heartwood"
+
    }
+
  },
+
  "delegates": [
+
    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
+
  ],
+
  "threshold": 2
+
}
```

-
Our key was added to the `Rejected` set of `keys` and the `total`
-
increased to `1`.
-

-
    Rejected
-

-
    total: 1
-
    keys: [
-
      "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
    ]
-

-
Instead, let's accept the proposal:
-

+
We can also look at the document's COB directly:
```
-
$ rad id accept 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
-
✓ Accepted proposal ✓
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
+
$ rad cob show --object 2317f74de0494c489a233ca6f29f2b8bff6d4f15 --type xyz.radicle.id --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
commit d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f
+
parent 2317f74de0494c489a233ca6f29f2b8bff6d4f15
+
author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date   Thu, 15 Dec 2022 17:28:04 +0000

+
    {
+
      "blob": "7109c1c201c223dd4e9fdb10f7330dc6f0310258",
+
      "description": "Add Bob as a delegate",
+
      "parent": "2317f74de0494c489a233ca6f29f2b8bff6d4f15",
+
      "signature": "z3sne3sdReZ4AtgxQmn7R1pQnz7E9ZEUoRfCJDJ8ytgnBMFW4DJqRHuBz2h1NK4QdGEy3QCpyVoJKfE95tNoivXwz",
+
      "title": "Add Bob",
+
      "type": "revision"
+
    }

-
Accepted
+
commit 2317f74de0494c489a233ca6f29f2b8bff6d4f15
+
author z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
date   Thu, 15 Dec 2022 17:28:04 +0000

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
+
    {
+
      "blob": "d96f425412c9f8ad5d9a9a05c9831d0728e2338d",
+
      "parent": null,
+
      "signature": "z5nGqUvrmfiSyLjNCHWTWYvVMcPUZcvo9TxPKzEKXYBdSgUzbrqf1cYsmpGgbQvYunnsrLSsubEmxZaRdKM4quqQR",
+
      "title": "Initial revision",
+
      "type": "revision"
+
    }

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
```

-
Our key has changed from the `Rejected` set to the `Accepted` set
-
instead:
-

-
    Accepted
-

-
    total: 1
-
    keys: [
-
      "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
    ]
-

-
As well as that, the `Quorum` has now been reached:
-

-
    Quorum Reached
-

-
    👍 yes
-

-
At this point, we can commit the proposal and update the identity:
+
Note that once a revision is accepted, it can't be edited, redacted or otherwise
+
acted upon:

+
``` (fail)
+
$ rad id redact d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f
+
✗ Error: [..]
```
-
$ rad id commit 662a8065f18db50d9ee952bb36eda5b605f161e9 --no-confirm
-
✓ Committed new identity 'c96e764965aaeff1c6ea3e5b97e2b9828773c8b0'
-
title: Add Bob
-
description: Add Bob as a delegate
-
status: ❲committed❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
-    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
+    "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
+    "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
   "threshold": 1
-
 }
-

-

-
Accepted
-

-
total: 1
-
keys: [
-
  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
]
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👍 yes
-
```
-

-
Let's say we decide to also change the `threshold`, we can do so using
-
the `--threshold` option:
-

-
```
-
$ rad id edit --title "Update threshold" --description "Update to safer threshold" --threshold 2 --no-confirm
-
✓ Identity proposal 'e6c04862ed0e59739f34232c8690cbad73840a93' created
-
title: Update threshold
-
description: Update to safer threshold
-
status: ❲open❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
-  "threshold": 1
-
+  "threshold": 2
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
But we change our minds and decide to close the proposal instead:
-

-
```
-
$ rad id close e6c04862ed0e59739f34232c8690cbad73840a93 --no-confirm
-
✓ Closed identity proposal 'e6c04862ed0e59739f34232c8690cbad73840a93'
-
title: Update threshold
-
description: Update to safer threshold
-
status: ❲closed❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
-  "threshold": 1
-
+  "threshold": 2
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
-
```
-

-
The proposal is now closed and cannot be committed. If at a later date
-
we want to update the document with the same change we have to open a
-
new proposal.
-

-
If at any time we want to see what proposals have been made to this
-
Radicle identity, then we can use the list command:
-

+
``` (fail)
+
$ rad id reject d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f
+
✗ Error: [..]
```
-
$ rad id list
-
662a8065f18db50d9ee952bb36eda5b605f161e9 "Add Bob"          ❲committed❳
-
e6c04862ed0e59739f34232c8690cbad73840a93 "Update threshold" ❲closed❳
-
```
-

-
And if we want to view the latest state of any proposal we can use the
-
show command:
-

-
```
-
$ rad id show e6c04862ed0e59739f34232c8690cbad73840a93
-
title: Update threshold
-
description: Update to safer threshold
-
status: ❲closed❳
-
author: did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-

-
Document Diff
-

-
 {
-
   "payload": {
-
     "xyz.radicle.project": {
-
       "defaultBranch": "master",
-
       "description": "Radicle Heartwood Protocol & Stack",
-
       "name": "heartwood"
-
     }
-
   },
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
     "did:key:z6MkedTZGJGqgQ2py2b8kGecfxdt2yRdHWF6JpaZC47fovFn"
-
   ],
-
-  "threshold": 1
-
+  "threshold": 2
-
 }
-

-

-
Accepted
-

-
total: 0
-
keys: []
-

-
Rejected
-

-
total: 0
-
keys: []
-

-
Quorum Reached
-

-
👎 no
+
``` (fail)
+
$ rad id accept d067ffc7bf65d318dd68ca4b8c7adf61a369ea2f
+
✗ Error: [..]
```
-

-
On a final note, these examples used `--no-confirm`. The default mode
-
for making proposals is to select and confirm any actions being
-
performed on the proposal.
modified radicle-cli/examples/rad-init-private-clone.md
@@ -16,11 +16,8 @@ She allows Bob to view the repository. And when she syncs, one node (Bob) gets
the refs.

``` ~alice
-
$ rad id edit --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
-
e98cd6a0a3e94837b382e59e02b3ea83991a8244
-
$ rad id accept e98cd6a0a3e94837b382e59e02b3ea83991a8244 -q
-
$ rad id commit e98cd6a0a3e94837b382e59e02b3ea83991a8244 -q
-
c568f8aac97db40a5e63e1261872bfbd9a3a61e4
+
$ rad id update --title "Allow Bob" --description "" --allow did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk -q
+
...
$ rad sync --announce --timeout 3
✓ Synced with 1 node(s)
```
modified radicle-cli/examples/rad-inspect.md
@@ -19,6 +19,9 @@ It's also possible to display all of the repository's git references:
$ rad inspect --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
+
    ├── cobs
+
    │   └── xyz.radicle.id
+
    │       └── 2317f74de0494c489a233ca6f29f2b8bff6d4f15
    ├── heads
    │   └── master
    └── rad
@@ -46,18 +49,13 @@ history:

```
$ rad inspect --history
-
commit 175267b8910895ba87760313af254c2900743912
+
commit 2317f74de0494c489a233ca6f29f2b8bff6d4f15
blob   d96f425412c9f8ad5d9a9a05c9831d0728e2338d
date   Thu, 15 Dec 2022 17:28:04 +0000

-
    Initialize Radicle
-

-
    Rad-Signature: z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi z5nGqUvrmfiSyLjNCHWTWYvVMcPUZcvo9TxPKzEKXYBdSgUzbrqf1cYsmpGgbQvYunnsrLSsubEmxZaRdKM4quqQR
+
    Initialize identity

 {
-
   "delegates": [
-
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
-
   ],
   "payload": {
     "xyz.radicle.project": {
       "defaultBranch": "master",
@@ -65,6 +63,9 @@ date Thu, 15 Dec 2022 17:28:04 +0000
       "name": "heartwood"
     }
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"
+
   ],
   "threshold": 1
 }

modified radicle-cli/examples/rad-issue.md
@@ -7,7 +7,7 @@ Let's say the new car you are designing with your peers has a problem with its f
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Status  open                                            │
│                                                         │
@@ -22,17 +22,17 @@ $ rad issue list
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●   ID        Title                         Author                    Labels   Assignees   Opened       │
├─────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   42028af   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                        [    ..    ] │
+
│ ●   9bf82c1   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)                        [    ..    ] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

Show the issue information issue.

```
-
$ rad issue show 42028af
+
$ rad issue show 9bf82c1
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Status  open                                            │
│                                                         │
@@ -49,7 +49,7 @@ others to work on. This is to ensure work is not duplicated.
Let's assign ourselves to this one.

```
-
$ rad assign 42028af21fabc09bfac2f25490f119f7c7e11542 --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad assign 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --to did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
```

It will now show in the list of issues assigned to us.
@@ -59,14 +59,14 @@ $ rad issue list --assigned
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●   ID        Title                         Author                    Labels   Assignees         Opened       │
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   42028af   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)            z6MknSL…StBU8Vi   [    ..    ] │
+
│ ●   9bf82c1   flux capacitor underpowered   z6MknSL…StBU8Vi   (you)            z6MknSL…StBU8Vi   [    ..    ] │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

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

```
-
$ rad unassign 42028af21fabc09bfac2f25490f119f7c7e11542 --from did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad unassign 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --from did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
```

Great, now we have communicated to the world about our car's defect.
@@ -75,28 +75,28 @@ But wait! We've found an important detail about the car's power requirements.
It will help whoever works on a fix.

```
-
$ rad issue comment 42028af21fabc09bfac2f25490f119f7c7e11542 --message 'The flux capacitor needs 1.21 Gigawatts' -q
-
84492237dc0908b1e5b728d1a4e5f1343b6ffe9b
-
$ rad issue comment 42028af21fabc09bfac2f25490f119f7c7e11542 --reply-to 84492237dc0908b1e5b728d1a4e5f1343b6ffe9b --message 'More power!' -q
-
dd679552a15e2db73bbedf3084f5f7c62bb0d724
+
$ rad issue comment 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --message 'The flux capacitor needs 1.21 Gigawatts' -q
+
1a8e9d3d62d22b247064b12d1d89ad8598504129
+
$ rad issue comment 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --reply-to 1a8e9d3d62d22b247064b12d1d89ad8598504129 --message 'More power!' -q
+
fb6ab7e0ca5be3c34688bcae37d7302bb824decf
```

We can see our comments by showing the issue:

```
-
$ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542
+
$ rad issue show 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Status  open                                            │
│                                                         │
│ Flux capacitor power requirements exceed current supply │
├─────────────────────────────────────────────────────────┤
-
│ z6MknSL…StBU8Vi (you) [   ...    ] 8449223              │
+
│ z6MknSL…StBU8Vi (you) [   ...    ] 1a8e9d3              │
│ The flux capacitor needs 1.21 Gigawatts                 │
├─────────────────────────────────────────────────────────┤
-
│ z6MknSL…StBU8Vi (you) [   ...    ] dd67955              │
+
│ z6MknSL…StBU8Vi (you) [   ...    ] fb6ab7e              │
│ More power!                                             │
╰─────────────────────────────────────────────────────────╯
```
modified radicle-cli/examples/rad-label.md
@@ -2,16 +2,16 @@ Labeling an issue is easy, let's add the `bug` and `good-first-issue` labels to
some issue:

```
-
$ rad label 42028af21fabc09bfac2f25490f119f7c7e11542 bug good-first-issue
+
$ rad label 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d bug good-first-issue
```

We can now show the issue to check whether those labels were added:

```
-
$ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542 --format header
+
$ rad issue show 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --format header
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Labels  bug, good-first-issue                           │
│ Status  open                                            │
@@ -23,16 +23,16 @@ $ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542 --format header
Untagging an issue is very similar:

```
-
$ rad unlabel 42028af21fabc09bfac2f25490f119f7c7e11542 good-first-issue
+
$ rad unlabel 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d good-first-issue
```

Notice that the `good-first-issue` label has disappeared:

```
-
$ rad issue show 42028af21fabc09bfac2f25490f119f7c7e11542 --format header
+
$ rad issue show 9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d --format header
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   42028af21fabc09bfac2f25490f119f7c7e11542        │
+
│ Issue   9bf82c141d5a9c54bb1d6b4517eb3bb7da8fb30d        │
│ Author  z6MknSL…StBU8Vi (you)                           │
│ Labels  bug                                             │
│ Status  open                                            │
modified radicle-cli/examples/rad-merge-after-update.md
@@ -4,7 +4,7 @@ Let's start by creating a patch.
$ git checkout -b feature/1 -q
$ git commit --allow-empty -q -m "First change"
$ git push rad HEAD:refs/patches
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 opened
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -26,8 +26,8 @@ update it, we expect it to be updated and merged:
``` (stderr) RAD_SOCKET=/dev/null
$ git checkout feature/1 -q
$ git push -f
-
✓ Patch 143bb0c updated to e595bf1246bdcee7b0c20615e479f62d2bf02249
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 merged
+
✓ Patch f6c96cc updated to [...]
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 merged
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 + 20aa5dd...954bcdb feature/1 -> patches/143bb0c962561b09e86478a53ba346b5ff934335 (forced update)
+
 + 20aa5dd...954bcdb feature/1 -> patches/f6c96cca58521d6dbb6cd4e6b7124342b9a86945 (forced update)
```
modified radicle-cli/examples/rad-merge-no-ff.md
@@ -4,7 +4,7 @@ First, let's create a patch.
$ git checkout -b feature/1 -q
$ git commit --allow-empty -q -m "First change"
$ git push rad HEAD:refs/patches
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 opened
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -36,7 +36,7 @@ committer radicle <radicle@localhost> 1671125284 +0000
Finally, we push master and expect the patch to be merged.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 merged
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 merged
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..737a10c  master -> master
```
modified radicle-cli/examples/rad-merge-via-push.md
@@ -4,7 +4,7 @@ Let's start by creating two patches.
$ git checkout -b feature/1 -q
$ git commit --allow-empty -q -m "First change"
$ git push rad HEAD:refs/patches
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 opened
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -12,7 +12,7 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
$ git checkout -b feature/2 -q master
$ git commit --allow-empty -q -m "Second change"
$ git push rad HEAD:refs/patches
-
✓ Patch 5d0e608aa35af59f769e9d6a2c0227ea60ae2740 opened
+
✓ Patch 3b8203713e2945a6c46b238e6a432bd2711d3ccf opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -22,8 +22,8 @@ This creates some remote tracking branches for us:
```
$ git branch -r
  rad/master
-
  rad/patches/143bb0c962561b09e86478a53ba346b5ff934335
-
  rad/patches/5d0e608aa35af59f769e9d6a2c0227ea60ae2740
+
  rad/patches/3b8203713e2945a6c46b238e6a432bd2711d3ccf
+
  rad/patches/f6c96cca58521d6dbb6cd4e6b7124342b9a86945
```

And some remote refs:
@@ -33,14 +33,16 @@ $ rad inspect --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
    ├── cobs
+
    │   ├── xyz.radicle.id
+
    │   │   └── 2317f74de0494c489a233ca6f29f2b8bff6d4f15
    │   └── xyz.radicle.patch
-
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
-
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
+
    │       ├── 3b8203713e2945a6c46b238e6a432bd2711d3ccf
+
    │       └── f6c96cca58521d6dbb6cd4e6b7124342b9a86945
    ├── heads
    │   ├── master
    │   └── patches
-
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
-
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
+
    │       ├── 3b8203713e2945a6c46b238e6a432bd2711d3ccf
+
    │       └── f6c96cca58521d6dbb6cd4e6b7124342b9a86945
    └── rad
        ├── id
        └── sigrefs
@@ -59,8 +61,8 @@ When we push to `rad/master`, we automatically merge the patches:

``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
-
✓ Patch 143bb0c962561b09e86478a53ba346b5ff934335 merged
-
✓ Patch 5d0e608aa35af59f769e9d6a2c0227ea60ae2740 merged
+
✓ Patch 3b8203713e2945a6c46b238e6a432bd2711d3ccf merged
+
✓ Patch f6c96cca58521d6dbb6cd4e6b7124342b9a86945 merged
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..d6399c7  master -> master
```
@@ -69,8 +71,8 @@ $ rad patch --merged
╭─────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title          Author         Head     +   -   Updated      │
├─────────────────────────────────────────────────────────────────────────┤
-
│ ✔  143bb0c  First change   alice   (you)  20aa5dd  +0  -0  [    ...   ] │
-
│ ✔  5d0e608  Second change  alice   (you)  daf349f  +0  -0  [    ...   ] │
+
│ ✔  [ ... ]  Second change  alice   (you)  daf349f  +0  -0  [    ...   ] │
+
│ ✔  [ ... ]  First change   alice   (you)  20aa5dd  +0  -0  [    ...   ] │
╰─────────────────────────────────────────────────────────────────────────╯
```

@@ -88,9 +90,11 @@ $ rad inspect --refs
z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
└── refs
    ├── cobs
+
    │   ├── xyz.radicle.id
+
    │   │   └── 2317f74de0494c489a233ca6f29f2b8bff6d4f15
    │   └── xyz.radicle.patch
-
    │       ├── 143bb0c962561b09e86478a53ba346b5ff934335
-
    │       └── 5d0e608aa35af59f769e9d6a2c0227ea60ae2740
+
    │       ├── 3b8203713e2945a6c46b238e6a432bd2711d3ccf
+
    │       └── f6c96cca58521d6dbb6cd4e6b7124342b9a86945
    ├── heads
    │   └── master
    └── rad
modified radicle-cli/examples/rad-patch-ahead-behind.md
@@ -37,7 +37,7 @@ $ git log --graph --decorate --abbrev-commit --pretty=oneline --all
Then we create a patch from `feature/1`:
``` (stderr)
$ git push rad feature/1:refs/patches
-
✓ Patch 69ebafb6f654fb29d23f630cc165d83d6cbf525c opened
+
✓ Patch 71e51dfcf7ca124a75ec6e0cb21b13bf86b8bb2e opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   feature/1 -> refs/patches
```
@@ -48,17 +48,17 @@ $ rad patch list
╭─────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title     Author                  Head     +   -   Updated      │
├─────────────────────────────────────────────────────────────────────────────┤
-
│ ●  69ebafb  Add Alan  z6MknSL…StBU8Vi  (you)  5c88a79  +1  -0  [   ...    ] │
+
│ ●  71e51df  Add Alan  z6MknSL…StBU8Vi  (you)  5c88a79  +1  -0  [   ...    ] │
╰─────────────────────────────────────────────────────────────────────────────╯
```

When showing the patch, we see that it is `ahead 1, behind 1`, since master has
diverged by one commit:
```
-
$ rad patch show -v -p 69ebafb
+
$ rad patch show -v -p 71e51df
╭────────────────────────────────────────────────────╮
│ Title     Add Alan                                 │
-
│ Patch     69ebafb6f654fb29d23f630cc165d83d6cbf525c │
+
│ Patch     71e51dfcf7ca124a75ec6e0cb21b13bf86b8bb2e │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 │
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943 │
@@ -93,7 +93,7 @@ $ git checkout -q -b feature/2 feature/1
$ sed -i '$a Mel Farna' CONTRIBUTORS
$ git commit -a -q -m "Add Mel"
$ git push -o patch.message="Add Mel" rad HEAD:refs/patches
-
✓ Patch 53d5f17aba5fd9b7de7a02ecb6f01de561701eeb opened
+
✓ Patch 364cc2809f14c1bc74a8868159e87eb3844eb7e2 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -101,10 +101,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
When we look at the patch, we see that it has both commits, because this new
patch uses the same base as the previous patch:
```
-
$ rad patch show -v 53d5f17aba5fd9b7de7a02ecb6f01de561701eeb
+
$ rad patch show -v 364cc2809f14c1bc74a8868159e87eb3844eb7e2
╭────────────────────────────────────────────────────╮
│ Title     Add Mel                                  │
-
│ Patch     53d5f17aba5fd9b7de7a02ecb6f01de561701eeb │
+
│ Patch     364cc2809f14c1bc74a8868159e87eb3844eb7e2 │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 │
│ Base      f64fb2c8fe28f7c458c72ec8d700373924794943 │
@@ -124,7 +124,7 @@ If we want to instead create a "stacked" patch, we can do so with the

``` (stderr)
$ git push -o patch.message="Add Mel #2" -o patch.base=5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 rad HEAD:refs/patches
-
✓ Patch 459dc67a024ff30c3bca02f0f1e5b746459ce32a opened
+
✓ Patch 11ab7fbec82c3aed393d7a696d6b3c7714735056 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -136,10 +136,10 @@ However, since the patch is still intended to be merged into `master`, we see
that it is still two commits ahead and one behind from `master`.

```
-
$ rad patch show -v 459dc67a024ff30c3bca02f0f1e5b746459ce32a
+
$ rad patch show -v 11ab7fbec82c3aed393d7a696d6b3c7714735056
╭────────────────────────────────────────────────────╮
│ Title     Add Mel #2                               │
-
│ Patch     459dc67a024ff30c3bca02f0f1e5b746459ce32a │
+
│ Patch     11ab7fbec82c3aed393d7a696d6b3c7714735056 │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      7f63fcbcf23fc39eea784c091ad3d20d7e4bd005 │
│ Base      5c88a79d75f5c2b4cc51ee6f163d2db91ee198d7 │
modified radicle-cli/examples/rad-patch-draft.md
@@ -9,7 +9,7 @@ To open a patch in draft mode, we use the `--draft` option:

``` (stderr)
$ git push -o patch.draft -o patch.message="Nothing yet" rad HEAD:refs/patches
-
✓ Patch 79a1a5138b7f91c6dead5544ecde285dc3d0cb45 drafted
+
✓ Patch 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc drafted
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -17,10 +17,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
We can confirm it's a draft by running `show`:

```
-
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
+
$ rad patch show 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc
╭────────────────────────────────────────────────────╮
│ Title     Nothing yet                              │
-
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45 │
+
│ Patch     78fcb007b4a3a898379f1e220d4b9fb54ad04cfc │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
│ Branches  cloudhead/draft                          │
@@ -36,14 +36,14 @@ $ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
Once the patch is ready for review, we can use the `ready` command:

```
-
$ rad patch ready 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
+
$ rad patch ready 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc
```

```
-
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
+
$ rad patch show 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc
╭────────────────────────────────────────────────────╮
│ Title     Nothing yet                              │
-
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45 │
+
│ Patch     78fcb007b4a3a898379f1e220d4b9fb54ad04cfc │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
│ Branches  cloudhead/draft                          │
@@ -60,11 +60,11 @@ If for whatever reason, it needed to go back into draft mode, we could use
the `--undo` flag:

```
-
$ rad patch ready --undo 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
-
$ rad patch show 79a1a5138b7f91c6dead5544ecde285dc3d0cb45
+
$ rad patch ready --undo 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc
+
$ rad patch show 78fcb007b4a3a898379f1e220d4b9fb54ad04cfc
╭────────────────────────────────────────────────────╮
│ Title     Nothing yet                              │
-
│ Patch     79a1a5138b7f91c6dead5544ecde285dc3d0cb45 │
+
│ Patch     78fcb007b4a3a898379f1e220d4b9fb54ad04cfc │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      2a465832b5a76abe25be44a3a5d224bbd7741ba7 │
│ Branches  cloudhead/draft                          │
modified radicle-cli/examples/rad-patch-pull-update.md
@@ -49,22 +49,22 @@ $ cd heartwood
$ git checkout -b bob/feature -q
$ git commit --allow-empty -m "Bob's commit #1" -q
$ git push rad -o sync -o patch.message="Bob's patch" HEAD:refs/patches
-
✓ Patch 26e3e563ddc7df8dd0c9f81274c0b3cb1b764568 opened
+
✓ Patch 6d260fc8388e74d8fefb5dabc5a798e125ec3cf9 opened
✓ Synced with 1 node(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
``` ~bob
$ git status --short --branch
-
## bob/feature...rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
+
## bob/feature...rad/patches/6d260fc8388e74d8fefb5dabc5a798e125ec3cf9
```

Alice checks it out.

``` ~alice
-
$ rad patch checkout 26e3e56
-
✓ Switched to branch patch/26e3e56
-
✓ Branch patch/26e3e56 setup to track rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
+
$ rad patch checkout 6d260fc8388e74d8fefb5dabc5a798e125ec3cf9
+
✓ Switched to branch patch/6d260fc
+
✓ Branch patch/6d260fc setup to track rad/patches/6d260fc8388e74d8fefb5dabc5a798e125ec3cf9
$ git show
commit bdcdb30b3c0f513620dd0f1c24ff8f4f71de956b
Author: radicle <radicle@localhost>
@@ -78,19 +78,19 @@ Bob then updates the patch.
``` ~bob (stderr)
$ git commit --allow-empty -m "Bob's commit #2" -q
$ git push rad -o sync -o patch.message="Updated."
-
✓ Patch 26e3e56 updated to c04ef81bad734c65a7d5834cefcdd60c4f0484f7
+
✓ Patch 6d260fc updated to 750081b35a3f831f428653bd2240eb4674ccae71
✓ Synced with 1 node(s)
To rad://zhbMU4DUXrzB8xT6qAJh6yZ7bFMK/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
   bdcdb30..cad2666  bob/feature -> patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
+
   bdcdb30..cad2666  bob/feature -> patches/6d260fc8388e74d8fefb5dabc5a798e125ec3cf9
```

Alice pulls the update.

``` ~alice
-
$ rad patch show 26e3e56
+
$ rad patch show 6d260fc
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title    Bob's patch                                                         │
-
│ Patch    26e3e563ddc7df8dd0c9f81274c0b3cb1b764568                            │
+
│ Patch    6d260fc8388e74d8fefb5dabc5a798e125ec3cf9                            │
│ Author   bob z6Mkt67…v4N1tRk                                                 │
│ Head     cad2666a8a2250e4dee175ed5044be2c251ff08b                            │
│ Commits  ahead 2, behind 0                                                   │
@@ -100,16 +100,16 @@ $ rad patch show 26e3e56
│ bdcdb30 Bob's commit #1                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob z6Mkt67…v4N1tRk [   ...    ]                                 │
-
│ ↑ updated to c04ef81bad734c65a7d5834cefcdd60c4f0484f7 (cad2666) [   ...    ] │
+
│ ↑ updated to 750081b35a3f831f428653bd2240eb4674ccae71 (cad2666) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
$ git ls-remote rad
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
-
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568
+
cad2666a8a2250e4dee175ed5044be2c251ff08b	refs/heads/patches/6d260fc8388e74d8fefb5dabc5a798e125ec3cf9
```
``` ~alice
$ git fetch rad
$ git status --short --branch
-
## patch/26e3e56...rad/patches/26e3e563ddc7df8dd0c9f81274c0b3cb1b764568 [behind 1]
+
## patch/6d260fc...rad/patches/6d260fc8388e74d8fefb5dabc5a798e125ec3cf9 [behind 1]
```
``` ~alice
$ git pull
modified radicle-cli/examples/rad-patch-update.md
@@ -6,16 +6,16 @@ $ git commit -q -m "Not a real change" --allow-empty
```
``` (stderr)
$ git push rad HEAD:refs/patches
-
✓ Patch ea6fa6c274c55d0f4fdf203a192cbf1330b51221 opened
+
✓ Patch 2541d346ba0b9377b3d38852dfded43f23833fc1 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```

```
-
$ rad patch show ea6fa6c274c55d0f4fdf203a192cbf1330b51221
+
$ rad patch show 2541d346ba0b9377b3d38852dfded43f23833fc1
╭────────────────────────────────────────────────────╮
│ Title     Not a real change                        │
-
│ Patch     ea6fa6c274c55d0f4fdf203a192cbf1330b51221 │
+
│ Patch     2541d346ba0b9377b3d38852dfded43f23833fc1 │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      51b2f0f77b9849bfaa3e9d3ff68ee2f57771d20c │
│ Branches  feature/1                                │
@@ -46,17 +46,17 @@ Now, instead of using `git push` to update the patch, as we normally would,
we run:

```
-
$ rad patch update ea6fa6c274c55d0f4fdf203a192cbf1330b51221 -m "Updated patch"
-
59bbb5c5d3c9f18a686113e6354b1372eebafda4
+
$ rad patch update 2541d346ba0b9377b3d38852dfded43f23833fc1 -m "Updated patch"
+
d9c9ef902f2957d746bb53e744e69a5c3aa564bc
```

The command outputs the new Revision ID, which we can now see here:

```
-
$ rad patch show ea6fa6c274c55d0f4fdf203a192cbf1330b51221
+
$ rad patch show 2541d346ba0b9377b3d38852dfded43f23833fc1
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Not a real change                                                  │
-
│ Patch     ea6fa6c274c55d0f4fdf203a192cbf1330b51221                           │
+
│ Patch     2541d346ba0b9377b3d38852dfded43f23833fc1                           │
│ Author    z6MknSL…StBU8Vi (you)                                              │
│ Head      4d272148458a17620541555b1f0905c01658aa9f                           │
│ Branches  feature/1                                                          │
@@ -67,6 +67,6 @@ $ rad patch show ea6fa6c274c55d0f4fdf203a192cbf1330b51221
│ 51b2f0f Not a real change                                                    │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) [     ...     ]                            │
-
│ ↑ updated to 59bbb5c5d3c9f18a686113e6354b1372eebafda4 (4d27214) [    ...   ] │
+
│ ↑ updated to d9c9ef902f2957d746bb53e744e69a5c3aa564bc (4d27214) [    ...   ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/examples/rad-patch-via-push.md
@@ -8,7 +8,7 @@ $ git checkout -b feature/1
Switched to a new branch 'feature/1'
$ git commit -a -m "Add things" -q --allow-empty
$ git push -o patch.message="Add things #1" -o patch.message="See commits for details." rad HEAD:refs/patches
-
✓ Patch 90c77f2c33b7e472e058de4a586156f8a7fec7d6 opened
+
✓ Patch e49e64637ab4a29e5a16c73000dacd2afa918d9d opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -16,10 +16,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
We can see a patch was created:

```
-
$ rad patch show 90c77f2
+
$ rad patch show e49e64637ab4a29e5a16c73000dacd2afa918d9d
╭────────────────────────────────────────────────────╮
│ Title     Add things #1                            │
-
│ Patch     90c77f2c33b7e472e058de4a586156f8a7fec7d6 │
+
│ Patch     e49e64637ab4a29e5a16c73000dacd2afa918d9d │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      42d894a83c9c356552a57af09ccdbd5587a99045 │
│ Branches  feature/1                                │
@@ -39,7 +39,7 @@ branch associated with this patch:

```
$ git branch -vv
-
* feature/1 42d894a [rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6] Add things
+
* feature/1 42d894a [rad/patches/e49e64637ab4a29e5a16c73000dacd2afa918d9d] Add things
  master    f2de534 [rad/master] Second commit
```

@@ -47,7 +47,7 @@ Let's check that it's up to date with our local head:

```
$ git status --short --branch
-
## feature/1...rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
+
## feature/1...rad/patches/e49e64637ab4a29e5a16c73000dacd2afa918d9d
$ git fetch
$ git push
```
@@ -59,13 +59,14 @@ $ git show-ref
42d894a83c9c356552a57af09ccdbd5587a99045 refs/heads/feature/1
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/heads/master
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 refs/remotes/rad/master
-
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
+
42d894a83c9c356552a57af09ccdbd5587a99045 refs/remotes/rad/patches/e49e64637ab4a29e5a16c73000dacd2afa918d9d
```
```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji 'refs/heads/patches/*'
-
42d894a83c9c356552a57af09ccdbd5587a99045	refs/heads/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6
+
42d894a83c9c356552a57af09ccdbd5587a99045	refs/heads/patches/e49e64637ab4a29e5a16c73000dacd2afa918d9d
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi 'refs/cobs/*'
-
90c77f2c33b7e472e058de4a586156f8a7fec7d6	refs/cobs/xyz.radicle.patch/90c77f2c33b7e472e058de4a586156f8a7fec7d6
+
2317f74de0494c489a233ca6f29f2b8bff6d4f15	refs/cobs/xyz.radicle.id/2317f74de0494c489a233ca6f29f2b8bff6d4f15
+
e49e64637ab4a29e5a16c73000dacd2afa918d9d	refs/cobs/xyz.radicle.patch/e49e64637ab4a29e5a16c73000dacd2afa918d9d
```

We can create another patch:
@@ -74,7 +75,7 @@ We can create another patch:
$ git checkout -b feature/2 -q master
$ git commit -a -m "Add more things" -q --allow-empty
$ git push rad HEAD:refs/patches
-
✓ Patch fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b opened
+
✓ Patch b1fd7b6883dca2ef11e0e486a7097e759ea90cdb opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -83,8 +84,8 @@ We see both branches with upstreams now:

```
$ git branch -vv
-
  feature/1 42d894a [rad/patches/90c77f2c33b7e472e058de4a586156f8a7fec7d6] Add things
-
* feature/2 8b0ea80 [rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b] Add more things
+
  feature/1 42d894a [rad/patches/e49e64637ab4a29e5a16c73000dacd2afa918d9d] Add things
+
* feature/2 8b0ea80 [rad/patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb] Add more things
  master    f2de534 [rad/master] Second commit
```

@@ -95,8 +96,8 @@ $ rad patch
╭────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title            Author                  Head     +   -   Updated      │
├────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  90c77f2  Add things #1    z6MknSL…StBU8Vi  (you)  42d894a  +0  -0  [    ...   ] │
-
│ ●  fedf0e4  Add more things  z6MknSL…StBU8Vi  (you)  8b0ea80  +0  -0  [    ...   ] │
+
│ ●  b1fd7b6  Add more things  z6MknSL…StBU8Vi  (you)  8b0ea80  +0  -0  [    ...   ] │
+
│ ●  e49e646  Add things #1    z6MknSL…StBU8Vi  (you)  42d894a  +0  -0  [    ...   ] │
╰────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -108,9 +109,9 @@ $ git commit -a -m "Improve code" -q --allow-empty

``` (stderr)
$ git push
-
✓ Patch fedf0e4 updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b
+
✓ Patch b1fd7b6 updated to c867846b9f294c271e8934820dfac2c5924ecd5a
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   8b0ea80..02bef3f  feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
+
   8b0ea80..02bef3f  feature/2 -> patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb
```

This last `git push` worked without specifying an upstream branch despite the
@@ -128,10 +129,10 @@ This allows for pushing to the remote patch branch without using the full
We can then see that the patch head has moved:

```
-
$ rad patch show fedf0e4
+
$ rad patch show b1fd7b6
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Add more things                                                    │
-
│ Patch     fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b                           │
+
│ Patch     b1fd7b6883dca2ef11e0e486a7097e759ea90cdb                           │
│ Author    z6MknSL…StBU8Vi (you)                                              │
│ Head      02bef3fac41b2f98bb3c02b868a53ddfecb55b5f                           │
│ Branches  feature/2                                                          │
@@ -142,7 +143,7 @@ $ rad patch show fedf0e4
│ 8b0ea80 Add more things                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) [   ...    ]                               │
-
│ ↑ updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b (02bef3f) [   ...    ] │
+
│ ↑ updated to c867846b9f294c271e8934820dfac2c5924ecd5a (02bef3f) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

@@ -155,14 +156,14 @@ $ git rev-parse HEAD

```
$ git status --short --branch
-
## feature/2...rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
+
## feature/2...rad/patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb
```

```
-
$ git rev-parse refs/remotes/rad/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
+
$ git rev-parse refs/remotes/rad/patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f
-
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
-
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f	refs/heads/patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b
+
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi refs/heads/patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb
+
02bef3fac41b2f98bb3c02b868a53ddfecb55b5f	refs/heads/patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb
```

## Force push
@@ -183,7 +184,7 @@ Now let's push to the patch head.
``` (stderr) (fail)
$ git push
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 ! [rejected]        feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b (non-fast-forward)
+
 ! [rejected]        feature/2 -> patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb (non-fast-forward)
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
hint: [..]
hint: [..]
@@ -196,18 +197,18 @@ use `--force` to force the update.

``` (stderr)
$ git push --force
-
✓ Patch fedf0e4 updated to 31ecf28817c44d90686b5c3c624c1f4a534b6478
+
✓ Patch b1fd7b6 updated to cf4d8577a1ec8aaa21a7ccca67ad8627c3304024
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 + 02bef3f...9304dbc feature/2 -> patches/fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b (forced update)
+
 + 02bef3f...9304dbc feature/2 -> patches/b1fd7b6883dca2ef11e0e486a7097e759ea90cdb (forced update)
```

That worked. We can see the new revision if we call `rad patch show`:

```
-
$ rad patch show fedf0e4
+
$ rad patch show b1fd7b6
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Add more things                                                    │
-
│ Patch     fedf0e4dcb74ff6db1d5e30a6a254b77f02ff60b                           │
+
│ Patch     b1fd7b6883dca2ef11e0e486a7097e759ea90cdb                           │
│ Author    z6MknSL…StBU8Vi (you)                                              │
│ Head      9304dbc445925187994a7a93222a3f8bde73b785                           │
│ Branches  feature/2                                                          │
@@ -218,8 +219,8 @@ $ rad patch show fedf0e4
│ 8b0ea80 Add more things                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) [   ...    ]                               │
-
│ ↑ updated to d0018fcc21d87c91a1ff9155aed6b4e57535566b (02bef3f) [   ...    ] │
-
│ ↑ updated to 31ecf28817c44d90686b5c3c624c1f4a534b6478 (9304dbc) [   ...    ] │
+
│ ↑ updated to c867846b9f294c271e8934820dfac2c5924ecd5a (02bef3f) [   ...    ] │
+
│ ↑ updated to cf4d8577a1ec8aaa21a7ccca67ad8627c3304024 (9304dbc) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

modified radicle-cli/examples/rad-patch.md
@@ -26,7 +26,7 @@ Once the code is ready, we open (or create) a patch with our changes for the pro

``` (stderr)
$ git push rad -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
-
✓ Patch 73b73f376e93e09e0419664766ac9e433bf7d389 opened
+
✓ Patch a8926643a8f6a65bc386b0131621994000485d4d opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -38,14 +38,14 @@ $ rad patch
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author                  Head     +   -   Updated      │
├──────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  73b73f3  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
+
│ ●  a892664  Define power requirements  z6MknSL…StBU8Vi  (you)  3e674d1  +0  -0  [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
```
```
-
$ rad patch show 73b73f376e93e09e0419664766ac9e433bf7d389 -p
+
$ rad patch show a8926643a8f6a65bc386b0131621994000485d4d -p
╭────────────────────────────────────────────────────╮
│ Title     Define power requirements                │
-
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389 │
+
│ Patch     a8926643a8f6a65bc386b0131621994000485d4d │
│ Author    z6MknSL…StBU8Vi (you)                    │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
│ Branches  flux-capacitor-power                     │
@@ -74,7 +74,7 @@ index 0000000..e69de29
We can also see that it set an upstream for our patch branch:
```
$ git branch -vv
-
* flux-capacitor-power 3e674d1 [rad/patches/73b73f376e93e09e0419664766ac9e433bf7d389] Define power requirements
+
* flux-capacitor-power 3e674d1 [rad/patches/a8926643a8f6a65bc386b0131621994000485d4d] Define power requirements
  master               f2de534 [rad/master] Second commit
```

@@ -90,48 +90,48 @@ $ git commit --message "Add README, just for the fun"
```
``` (stderr)
$ git push rad -o patch.message="Add README, just for the fun"
-
✓ Patch 73b73f3 updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b
+
✓ Patch a892664 updated to 8d8aa0887a11f2a37fa8ed0d5723efa96fd727ed
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
   3e674d1..27857ec  flux-capacitor-power -> patches/73b73f376e93e09e0419664766ac9e433bf7d389
+
   3e674d1..27857ec  flux-capacitor-power -> patches/a8926643a8f6a65bc386b0131621994000485d4d
```

And let's leave a quick comment for our team:

```
-
$ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'I cannot wait to get back to the 90s!'
+
$ rad patch comment a8926643a8f6a65bc386b0131621994000485d4d --message 'I cannot wait to get back to the 90s!'
╭────────────────────────────────────────────╮
-
│ z6MknSL…StBU8Vi (you) [   ...    ] 5c418a5 │
+
│ z6MknSL…StBU8Vi (you) [   ...    ] b97a27f │
│ I cannot wait to get back to the 90s!      │
╰────────────────────────────────────────────╯
-
$ rad patch comment 73b73f376e93e09e0419664766ac9e433bf7d389 --message 'My favorite decade!' --reply-to 5c418a5 -q
-
729cdf63ce4793ab3cabffbe0dce24db16e45549
+
$ rad patch comment a8926643a8f6a65bc386b0131621994000485d4d --message 'My favorite decade!' --reply-to b97a27f -q
+
a3a462bc8ab52e2a6f3568c28a11ba53cf40bbc8
```

Now, let's checkout the patch that we just created:

```
-
$ rad patch checkout 73b73f3
-
✓ Switched to branch patch/73b73f3
-
✓ Branch patch/73b73f3 setup to track rad/patches/73b73f376e93e09e0419664766ac9e433bf7d389
+
$ rad patch checkout a892664
+
✓ Switched to branch patch/a892664
+
✓ Branch patch/a892664 setup to track rad/patches/a8926643a8f6a65bc386b0131621994000485d4d
```

We can also add a review verdict as such:

```
-
$ rad review 73b73f376e93e09e0419664766ac9e433bf7d389 --accept --no-message --no-sync
-
✓ Patch 73b73f3 accepted
+
$ rad review a8926643a8f6a65bc386b0131621994000485d4d --accept --no-message --no-sync
+
✓ Patch a892664 accepted
```

Showing the patch list now will reveal the favorable verdict:

```
-
$ rad patch show 73b73f3
+
$ rad patch show a892664
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                          │
-
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389                           │
+
│ Patch     a8926643a8f6a65bc386b0131621994000485d4d                           │
│ Author    z6MknSL…StBU8Vi (you)                                              │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                           │
-
│ Branches  flux-capacitor-power, patch/73b73f3                                │
+
│ Branches  flux-capacitor-power, patch/a892664                                │
│ Commits   ahead 2, behind 0                                                  │
│ Status    open                                                               │
│                                                                              │
@@ -141,7 +141,7 @@ $ rad patch show 73b73f3
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) [   ...    ]                               │
-
│ ↑ updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b (27857ec) [   ...    ] │
+
│ ↑ updated to 8d8aa0887a11f2a37fa8ed0d5723efa96fd727ed (27857ec) [   ...    ] │
│ ✓ accepted by z6MknSL…StBU8Vi (you) [   ...    ]                             │
╰──────────────────────────────────────────────────────────────────────────────╯
```
@@ -149,14 +149,14 @@ $ rad patch show 73b73f3
If you make a mistake on the patch description, you can always change it!

```
-
$ rad patch edit 73b73f3 --message "Define power requirements" --message "Add requirements file"
-
$ rad patch show 73b73f3
+
$ rad patch edit a892664 --message "Define power requirements" --message "Add requirements file"
+
$ rad patch show a892664
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title     Define power requirements                                          │
-
│ Patch     73b73f376e93e09e0419664766ac9e433bf7d389                           │
+
│ Patch     a8926643a8f6a65bc386b0131621994000485d4d                           │
│ Author    z6MknSL…StBU8Vi (you)                                              │
│ Head      27857ec9eb04c69cacab516e8bf4b5fd36090f66                           │
-
│ Branches  flux-capacitor-power, patch/73b73f3                                │
+
│ Branches  flux-capacitor-power, patch/a892664                                │
│ Commits   ahead 2, behind 0                                                  │
│ Status    open                                                               │
│                                                                              │
@@ -166,7 +166,7 @@ $ rad patch show 73b73f3
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by z6MknSL…StBU8Vi (you) [   ...    ]                               │
-
│ ↑ updated to 5605784ae81dad91ba47ea55e19dd16f6280d44b (27857ec) [   ...    ] │
+
│ ↑ updated to 8d8aa0887a11f2a37fa8ed0d5723efa96fd727ed (27857ec) [   ...    ] │
│ ✓ accepted by z6MknSL…StBU8Vi (you) [   ...    ]                             │
╰──────────────────────────────────────────────────────────────────────────────╯
```
modified radicle-cli/examples/rad-review-by-hunk.md
@@ -61,7 +61,7 @@ $ git commit -q -m "Update files"

``` (stderr)
$ git push rad HEAD:refs/patches
-
✓ Patch 7f6ed9bd562a36eb1d5689f95600d09247726a23 opened
+
✓ Patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 * [new reference]   HEAD -> refs/patches
```
@@ -70,7 +70,7 @@ Finally, we do a review of the patch by hunk. The output of this command should
match `git diff master -W100% -U5 --patience`:

```
-
$ rad review --no-sync --patch -U5 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch -U5 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7937fb3..0000000
@@ -116,8 +116,8 @@ rename to notes/INSTRUCTIONS.txt
Now let's accept these hunks one by one..

```
-
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 7937fb3..0000000
@@ -127,8 +127,8 @@ index 7937fb3..0000000
-*.draft
```
```
-
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/DISCLAIMER.txt b/DISCLAIMER.txt
new file mode 100644
index 0000000..2b5bd86
@@ -138,8 +138,8 @@ index 0000000..2b5bd86
+All food is served as-is, with no warranty!
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept -U3 --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/MENU.txt b/MENU.txt
index 867958c..3af9741 100644
--- a/MENU.txt
@@ -153,8 +153,8 @@ index 867958c..3af9741 100644
[..]
```
```
-
$ rad review --no-sync --patch --accept -U3 --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept -U3 --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/MENU.txt b/MENU.txt
index 4e2e828..3af9741 100644
--- a/MENU.txt
@@ -169,8 +169,8 @@ index 4e2e828..3af9741 100644
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
diff --git a/INSTRUCTIONS.txt b/notes/INSTRUCTIONS.txt
similarity index 100%
rename from INSTRUCTIONS.txt
@@ -178,7 +178,7 @@ rename to notes/INSTRUCTIONS.txt
```

```
-
$ rad review --no-sync --patch --accept --hunk 1 7f6ed9bd562a36eb1d5689f95600d09247726a23
-
✓ Loaded existing review ([..]) for patch 7f6ed9bd562a36eb1d5689f95600d09247726a23
+
$ rad review --no-sync --patch --accept --hunk 1 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
+
✓ Loaded existing review ([..]) for patch 4f4fdb0fbb3327975b92fd1fa88e7427b1b141e2
✓ All hunks have been reviewed
```
modified radicle-cli/examples/workflow/3-issues.md
@@ -7,7 +7,7 @@ Let's say the new car you are designing with your peers has a problem with its f
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
╭─────────────────────────────────────────────────────────╮
│ Title   flux capacitor underpowered                     │
-
│ Issue   2f6eb49efac492327f71437b6bfc01b49afa0981        │
+
│ Issue   d457c205de780d37d1c16efbe5333aed4662a6c3        │
│ Author  bob (you)                                       │
│ Status  open                                            │
│                                                         │
@@ -22,7 +22,7 @@ $ rad issue list
╭────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ●   ID        Title                         Author           Labels   Assignees   Opened       │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●   2f6eb49   flux capacitor underpowered   bob      (you)                        [    ..    ] │
+
│ ●   d457c20   flux capacitor underpowered   bob      (you)                        [    ..    ] │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
```

@@ -31,6 +31,6 @@ found an important detail about the car's power requirements. It will help
whoever works on a fix.

```
-
$ rad issue comment 2f6eb49efac492327f71437b6bfc01b49afa0981 --message 'The flux capacitor needs 1.21 Gigawatts' -q
-
d2b50873009b93680698aef4f57f43f7e850b651
+
$ rad issue comment d457c205de780d37d1c16efbe5333aed4662a6c3 --message 'The flux capacitor needs 1.21 Gigawatts' -q
+
2e8b9538aedaea418e90e90d19e61edf3f022933
```
modified radicle-cli/examples/workflow/4-patching-contributor.md
@@ -26,7 +26,7 @@ Once the code is ready, we open a patch with our changes.

``` (stderr)
$ git push rad -o no-sync -o patch.message="Define power requirements" -o patch.message="See details." HEAD:refs/patches
-
✓ Patch 69e881c606639691330051d7d8f013854f32fb87 opened
+
✓ Patch 4bfb6fe940f815e3fcce6a2796e051df85db9fe1 opened
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new reference]   HEAD -> refs/patches
```
@@ -38,12 +38,12 @@ $ rad patch
╭─────────────────────────────────────────────────────────────────────────────────────╮
│ ●  ID       Title                      Author         Head     +   -   Updated      │
├─────────────────────────────────────────────────────────────────────────────────────┤
-
│ ●  69e881c  Define power requirements  bob     (you)  3e674d1  +0  -0  [    ...   ] │
+
│ ●  4bfb6fe  Define power requirements  bob     (you)  3e674d1  +0  -0  [    ...   ] │
╰─────────────────────────────────────────────────────────────────────────────────────╯
-
$ rad patch show 69e881c606639691330051d7d8f013854f32fb87
+
$ rad patch show 4bfb6fe940f815e3fcce6a2796e051df85db9fe1
╭────────────────────────────────────────────────────╮
│ Title     Define power requirements                │
-
│ Patch     69e881c606639691330051d7d8f013854f32fb87 │
+
│ Patch     4bfb6fe940f815e3fcce6a2796e051df85db9fe1 │
│ Author    bob (you)                                │
│ Head      3e674d1a1df90807e934f9ae5da2591dd6848a33 │
│ Branches  flux-capacitor-power                     │
@@ -62,7 +62,7 @@ We can also confirm that the patch branch is in storage:

```
$ git ls-remote rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk refs/heads/patches/*
-
3e674d1a1df90807e934f9ae5da2591dd6848a33	refs/heads/patches/69e881c606639691330051d7d8f013854f32fb87
+
3e674d1a1df90807e934f9ae5da2591dd6848a33	refs/heads/patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
```

Wait, let's add a README too! Just for fun.
@@ -77,14 +77,14 @@ $ git commit --message "Add README, just for the fun"
```
``` (stderr) RAD_SOCKET=/dev/null
$ git push -o patch.message="Add README, just for the fun"
-
✓ Patch 69e881c updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821
+
✓ Patch 4bfb6fe updated to 7782e60eb51b6e852abb184b092249327354c625
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
-
   3e674d1..27857ec  flux-capacitor-power -> patches/69e881c606639691330051d7d8f013854f32fb87
+
   3e674d1..27857ec  flux-capacitor-power -> patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
```

And let's leave a quick comment for our team:

```
-
$ rad patch comment 69e881c606639691330051d7d8f013854f32fb87 --message 'I cannot wait to get back to the 90s!' -q
-
c758bd868bb7d6c8509ee9168b3876082a8e377c
+
$ rad patch comment 4bfb6fe940f815e3fcce6a2796e051df85db9fe1 --message 'I cannot wait to get back to the 90s!' -q
+
a3ba1df99fbd874affc790c95cd18607940f1a90
```
modified radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -22,7 +22,7 @@ $ git fetch bob
✓ Synced with 1 peer(s)
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
 * [new branch]      master     -> bob/master
-
 * [new branch]      patches/69e881c606639691330051d7d8f013854f32fb87 -> bob/patches/69e881c606639691330051d7d8f013854f32fb87
+
 * [new branch]      patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1 -> bob/patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
```

The contributor's changes are now visible to us.
@@ -30,12 +30,12 @@ The contributor's changes are now visible to us.
```
$ git branch -r
  bob/master
-
  bob/patches/69e881c606639691330051d7d8f013854f32fb87
+
  bob/patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
  rad/master
-
$ rad patch show 69e881c
+
$ rad patch show 4bfb6fe
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title    Define power requirements                                           │
-
│ Patch    69e881c606639691330051d7d8f013854f32fb87                            │
+
│ Patch    4bfb6fe940f815e3fcce6a2796e051df85db9fe1                            │
│ Author   bob z6Mkt67…v4N1tRk                                                 │
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66                            │
│ Commits  ahead 2, behind 0                                                   │
@@ -47,7 +47,7 @@ $ rad patch show 69e881c
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob z6Mkt67…v4N1tRk [   ...    ]                                 │
-
│ ↑ updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821 (27857ec) [   ...    ] │
+
│ ↑ updated to 7782e60eb51b6e852abb184b092249327354c625 (27857ec) [   ...    ] │
╰──────────────────────────────────────────────────────────────────────────────╯
```

@@ -57,20 +57,20 @@ way will tell others about the corrections we needed before merging the
changes.

```
-
$ rad patch checkout 69e881c606639691330051d7d8f013854f32fb87
-
✓ Switched to branch patch/69e881c
-
✓ Branch patch/69e881c setup to track rad/patches/69e881c606639691330051d7d8f013854f32fb87
+
$ rad patch checkout 4bfb6fe940f815e3fcce6a2796e051df85db9fe1
+
✓ Switched to branch patch/4bfb6fe
+
✓ Branch patch/4bfb6fe setup to track rad/patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
$ git mv REQUIREMENTS REQUIREMENTS.md
$ git commit -m "Use markdown for requirements"
-
[patch/69e881c f567f69] Use markdown for requirements
+
[patch/4bfb6fe f567f69] Use markdown for requirements
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename REQUIREMENTS => REQUIREMENTS.md (100%)
```
``` (stderr)
$ git push rad -o no-sync -o patch.message="Use markdown for requirements"
-
✓ Patch 69e881c updated to 70dd9a31882d184a9fe8f1f590471f5543c4d85b
+
✓ Patch 4bfb6fe updated to fab4fddf6bcae7d55432417cdf5a7d0270d0d7d3
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
-
 * [new branch]      patch/69e881c -> patches/69e881c606639691330051d7d8f013854f32fb87
+
 * [new branch]      patch/4bfb6fe -> patches/4bfb6fe940f815e3fcce6a2796e051df85db9fe1
```

Great, all fixed up, lets merge the code.
@@ -78,7 +78,7 @@ Great, all fixed up, lets merge the code.
```
$ git checkout master
Your branch is up to date with 'rad/master'.
-
$ git merge patch/69e881c
+
$ git merge patch/4bfb6fe
Updating f2de534..f567f69
Fast-forward
 README.md       | 0
@@ -89,7 +89,7 @@ Fast-forward
```
``` (stderr)
$ git push rad master
-
✓ Patch 69e881c606639691330051d7d8f013854f32fb87 merged at revision 70dd9a3
+
✓ Patch 4bfb6fe940f815e3fcce6a2796e051df85db9fe1 merged at revision fab4fdd
✓ Synced with 1 node(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f567f69  master -> master
@@ -98,10 +98,10 @@ To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkE
The patch is now merged and closed :).

```
-
$ rad patch show 69e881c
+
$ rad patch show 4bfb6fe
╭──────────────────────────────────────────────────────────────────────────────╮
│ Title    Define power requirements                                           │
-
│ Patch    69e881c606639691330051d7d8f013854f32fb87                            │
+
│ Patch    4bfb6fe940f815e3fcce6a2796e051df85db9fe1                            │
│ Author   bob z6Mkt67…v4N1tRk                                                 │
│ Head     27857ec9eb04c69cacab516e8bf4b5fd36090f66                            │
│ Commits  ahead 0, behind 1                                                   │
@@ -113,9 +113,9 @@ $ rad patch show 69e881c
│ 3e674d1 Define power requirements                                            │
├──────────────────────────────────────────────────────────────────────────────┤
│ ● opened by bob z6Mkt67…v4N1tRk [     ...    ]                               │
-
│ ↑ updated to dcf3e6dd97c95cf8653cbb8ce47df20d28eb1821 (27857ec) [   ...    ] │
-
│ * revised by alice (you) in 70dd9a3 (f567f69) [   ...    ]                   │
-
│ ✓ merged by alice (you) at revision 70dd9a3 (f567f69) [    ...    ]          │
+
│ ↑ updated to 7782e60eb51b6e852abb184b092249327354c625 (27857ec) [   ...    ] │
+
│ * revised by alice (you) in fab4fdd (f567f69) [   ...    ]                   │
+
│ ✓ merged by alice (you) at revision fab4fdd (f567f69) [    ...    ]          │
╰──────────────────────────────────────────────────────────────────────────────╯
```

modified radicle-cli/src/commands.rs
@@ -8,12 +8,8 @@ pub mod rad_checkout;
pub mod rad_clone;
#[path = "commands/cob.rs"]
pub mod rad_cob;
-
#[path = "commands/delegate.rs"]
-
pub mod rad_delegate;
#[path = "commands/diff.rs"]
pub mod rad_diff;
-
#[path = "commands/edit.rs"]
-
pub mod rad_edit;
#[path = "commands/fork.rs"]
pub mod rad_fork;
#[path = "commands/help.rs"]
modified radicle-cli/src/commands/checkout.rs
@@ -82,10 +82,11 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
    let id = options.id;
    let storage = &profile.storage;
    let remote = options.remote.unwrap_or(profile.did());
-
    let doc = storage
+
    let doc: Doc<_> = storage
        .repository(id)?
-
        .identity_doc_of(&remote)
-
        .context("project could not be found in local storage")?;
+
        .identity_doc()
+
        .context("project could not be found in local storage")?
+
        .into();
    let payload = doc.project()?;
    let path = PathBuf::from(payload.name());

modified radicle-cli/src/commands/clone.rs
@@ -9,8 +9,8 @@ use thiserror::Error;

use radicle::cob;
use radicle::git::raw;
+
use radicle::identity::doc;
use radicle::identity::doc::{DocError, Id};
-
use radicle::identity::{doc, IdentityError};
use radicle::node;
use radicle::node::tracking::Scope;
use radicle::node::{Handle as _, Node};
@@ -18,6 +18,7 @@ use radicle::prelude::*;
use radicle::rad;
use radicle::storage;
use radicle::storage::git::Storage;
+
use radicle::storage::RepositoryError;

use crate::commands::rad_checkout as checkout;
use crate::commands::rad_sync as sync;
@@ -188,8 +189,8 @@ pub enum CloneError {
    Doc(#[from] DocError),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
-
    #[error("project error: {0}")]
-
    Identity(#[from] IdentityError),
+
    #[error(transparent)]
+
    Repository(#[from] RepositoryError),
    #[error("repository {0} not found")]
    NotFound(Id),
    #[error("no seeds found for {0}")]
@@ -258,7 +259,7 @@ pub fn clone<G: Signer>(
        }
    }

-
    let doc = repository.identity_doc_of(&me)?;
+
    let doc = repository.identity_doc()?;
    let proj = doc.project()?;
    let path = Path::new(proj.name());

@@ -279,5 +280,5 @@ pub fn clone<G: Signer>(

    spinner.finish();

-
    Ok((working, repository, doc, proj))
+
    Ok((working, repository, doc.into(), proj))
}
modified radicle-cli/src/commands/cob.rs
@@ -137,7 +137,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                .to_rfc2822();

                term::print(term::format::yellow(format!("commit {}", op.id)));
-
                term::print(term::format::tertiary(format!("parent {}", op.identity)));
+
                if let Some(oid) = op.identity {
+
                    term::print(term::format::tertiary(format!("parent {oid}")));
+
                }
                for parent in op.parents {
                    term::print(format!("parent {}", parent));
                }
deleted radicle-cli/src/commands/delegate.rs
@@ -1,129 +0,0 @@
-
use std::ffi::OsString;
-

-
use anyhow::{anyhow, Context as _};
-

-
use radicle::identity::Id;
-
use radicle::prelude::Did;
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
#[path = "delegate/add.rs"]
-
mod add;
-
#[path = "delegate/list.rs"]
-
mod list;
-
#[path = "delegate/remove.rs"]
-
mod remove;
-

-
pub const HELP: Help = Help {
-
    name: "delegate",
-
    description: "Manage the delegates of an identity",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad delegate add <did> [--to <rid>] [<option>...]
-
    rad delegate remove <did> [--to <rid>] [<option>...]
-
    rad delegate list [<rid>] [<option>...]
-

-
    The `add` and `remove` commands are limited to managing delegates
-
    where the `threshold` for the quorum is exactly `1`. Otherwise,
-
    the verification of the document will not be able to gather enough
-
    signatures to pass the quorum.
-

-
Options
-

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

-
#[derive(Debug, Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Add,
-
    Remove,
-
    #[default]
-
    List,
-
}
-

-
#[derive(Debug, Eq, PartialEq)]
-
pub enum Operation {
-
    Add { id: Option<Id>, did: Did },
-
    Remove { id: Option<Id>, did: Did },
-
    List { id: Option<Id> },
-
}
-

-
#[derive(Debug, Eq, PartialEq)]
-
pub struct Options {
-
    pub op: Operation,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id: Option<Id> = None;
-
        let mut op: Option<OperationName> = None;
-
        let mut did: Option<Did> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("to") => {
-
                    id = Some(parser.value()?.parse::<Id>()?);
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "a" | "add" => op = Some(OperationName::Add),
-
                    "r" | "remove" => op = Some(OperationName::Remove),
-
                    "l" | "list" => op = Some(OperationName::List),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if op.is_some() => match op {
-
                    Some(OperationName::Add) | Some(OperationName::Remove) => {
-
                        did = Some(term::args::did(&val)?);
-
                    }
-
                    Some(OperationName::List) => {
-
                        id = Some(term::args::rid(&val)?);
-
                    }
-
                    None => continue,
-
                },
-
                _ => return Err(anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::List => Operation::List { id },
-
            OperationName::Add => Operation::Add {
-
                id,
-
                did: did.ok_or_else(|| anyhow!("a delegate DID must be provided"))?,
-
            },
-
            OperationName::Remove => Operation::Remove {
-
                id,
-
                did: did.ok_or_else(|| anyhow!("a delegate DID must be provided"))?,
-
            },
-
        };
-

-
        Ok((Options { op }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let profile = ctx.profile()?;
-
    let storage = &profile.storage;
-

-
    match options.op {
-
        Operation::Add { id, did } => add::run(get_id(id)?, *did, &profile, storage)?,
-
        Operation::Remove { id, did } => remove::run(get_id(id)?, &did, &profile, storage)?,
-
        Operation::List { id } => list::run(get_id(id)?, &profile, storage)?,
-
    }
-

-
    Ok(())
-
}
-

-
fn get_id(id: Option<Id>) -> anyhow::Result<Id> {
-
    id.or_else(|| radicle::rad::cwd().ok().map(|(_, id)| id))
-
        .context("Couldn't get the RID from either command line or cwd")
-
}
deleted radicle-cli/src/commands/delegate/add.rs
@@ -1,54 +0,0 @@
-
use anyhow::Context as _;
-

-
use radicle::{
-
    prelude::{Did, Id},
-
    storage::{SignRepository, WriteRepository as _, WriteStorage},
-
    Profile,
-
};
-
use radicle_crypto::PublicKey;
-

-
use crate::terminal as term;
-

-
pub fn run<S>(id: Id, key: PublicKey, profile: &Profile, storage: &S) -> anyhow::Result<()>
-
where
-
    S: WriteStorage,
-
{
-
    let signer = term::signer(profile)?;
-
    let me = signer.public_key();
-

-
    let mut project = storage
-
        .get(&profile.public_key, id)?
-
        .context("No project with the given RID exists")?;
-

-
    let repo = storage.repository_mut(id)?;
-

-
    if !project.is_delegate(me) {
-
        return Err(anyhow::anyhow!(
-
            "'{}' is not a delegate of the project, only a delegate may add this key",
-
            me
-
        ));
-
    }
-

-
    if project.threshold > 1 {
-
        return Err(anyhow::anyhow!("project threshold > 1"));
-
    }
-

-
    if project.delegate(&key) {
-
        project.sign(&signer).and_then(|(_, sig)| {
-
            project.update(
-
                signer.public_key(),
-
                "Updated payload",
-
                &[(signer.public_key(), sig)],
-
                repo.raw(),
-
            )
-
        })?;
-
        repo.sign_refs(&signer)?;
-
        repo.set_identity_head()?;
-
        term::info!("Added delegate '{}'", Did::from(key));
-
        term::success!("Update successful!");
-
        Ok(())
-
    } else {
-
        term::info!("the delegate for '{}' already exists", key);
-
        Ok(())
-
    }
-
}
deleted radicle-cli/src/commands/delegate/list.rs
@@ -1,17 +0,0 @@
-
use anyhow::Context as _;
-

-
use radicle::{prelude::Id, storage::ReadStorage, Profile};
-

-
use crate::terminal as term;
-

-
pub fn run<S>(id: Id, profile: &Profile, storage: &S) -> anyhow::Result<()>
-
where
-
    S: ReadStorage,
-
{
-
    let project = storage
-
        .get(&profile.public_key, id)?
-
        .context("No project with the given RID exists")?;
-

-
    term::info!("{}", serde_json::to_string_pretty(&project.delegates)?);
-
    Ok(())
-
}
deleted radicle-cli/src/commands/delegate/remove.rs
@@ -1,55 +0,0 @@
-
use anyhow::Context as _;
-

-
use radicle::{
-
    prelude::Id,
-
    storage::{WriteRepository as _, WriteStorage},
-
    Profile,
-
};
-
use radicle_crypto::PublicKey;
-

-
use crate::terminal as term;
-

-
pub fn run<S>(id: Id, key: &PublicKey, profile: &Profile, storage: &S) -> anyhow::Result<()>
-
where
-
    S: WriteStorage,
-
{
-
    let signer = term::signer(profile)?;
-
    let me = signer.public_key();
-

-
    let mut project = storage
-
        .get(&profile.public_key, id)?
-
        .context("No project with the given RID exists")?;
-

-
    let repo = storage.repository_mut(id)?;
-

-
    if !project.is_delegate(me) {
-
        return Err(anyhow::anyhow!(
-
            "'{}' is not a delegate of the project, only a delegate may remove this key",
-
            me
-
        ));
-
    }
-

-
    if project.threshold > 1 {
-
        return Err(anyhow::anyhow!("project threshold > 1"));
-
    }
-

-
    match project.rescind(key)? {
-
        Some(delegate) => {
-
            project.sign(&signer).and_then(|(_, sig)| {
-
                project.update(
-
                    signer.public_key(),
-
                    "Updated payload",
-
                    &[(signer.public_key(), sig)],
-
                    repo.raw(),
-
                )
-
            })?;
-
            term::info!("Removed delegate '{}'", delegate);
-
            term::success!("Update successful!");
-
            Ok(())
-
        }
-
        None => {
-
            term::info!("the delegate for '{}' did not exist", key);
-
            Ok(())
-
        }
-
    }
-
}
deleted radicle-cli/src/commands/edit.rs
@@ -1,92 +0,0 @@
-
use std::ffi::OsString;
-

-
use anyhow::{anyhow, Context as _};
-

-
use radicle::identity::Id;
-
use radicle::storage::{ReadStorage, WriteRepository};
-

-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-

-
pub const HELP: Help = Help {
-
    name: "edit",
-
    description: "Edit an identity doc",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad edit [<rid>] [<option>...]
-

-
    Edits the identity document pointed to by the RID. If it isn't specified,
-
    the current project is edited.
-

-
Options
-

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

-
#[derive(Default, Debug, Eq, PartialEq)]
-
pub struct Options {
-
    pub id: Option<Id>,
-
}
-

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

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut id: Option<Id> = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Value(val) if id.is_none() => {
-
                    id = Some(term::args::rid(&val)?);
-
                }
-
                _ => return Err(anyhow::anyhow!(arg.unexpected())),
-
            }
-
        }
-

-
        Ok((Options { id }, vec![]))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let profile = ctx.profile()?;
-
    let signer = term::signer(&profile)?;
-
    let storage = &profile.storage;
-

-
    let id = options
-
        .id
-
        .or_else(|| radicle::rad::cwd().ok().map(|(_, id)| id))
-
        .context("Couldn't get RID from either command line or cwd")?;
-

-
    let mut project = storage
-
        .get(signer.public_key(), id)?
-
        .context("No project with the given RID exists")?;
-

-
    let repo = storage.repository(id)?;
-

-
    let payload = serde_json::to_string_pretty(&project.payload)?;
-
    match term::Editor::new().extension("json").edit(payload)? {
-
        Some(updated_payload) => {
-
            project.payload = serde_json::from_str(&updated_payload)?;
-
            project.sign(&signer).and_then(|(_, sig)| {
-
                project.update(
-
                    signer.public_key(),
-
                    "Update payload",
-
                    &[(signer.public_key(), sig)],
-
                    repo.raw(),
-
                )
-
            })?;
-
        }
-
        _ => return Err(anyhow!("Operation aborted!")),
-
    }
-

-
    term::success!("Update successful!");
-

-
    Ok(())
-
}
modified radicle-cli/src/commands/help.rs
@@ -17,7 +17,6 @@ const COMMANDS: &[Help] = &[
    rad_auth::HELP,
    rad_checkout::HELP,
    rad_clone::HELP,
-
    rad_edit::HELP,
    rad_fork::HELP,
    rad_help::HELP,
    rad_id::HELP,
modified radicle-cli/src/commands/id.rs
@@ -1,104 +1,80 @@
-
use std::io::IsTerminal;
-
use std::{ffi::OsString, io, str::FromStr as _};
+
use std::{ffi::OsString, io};

-
use anyhow::{anyhow, Context as _};
+
use anyhow::{anyhow, Context};

-
use radicle::cob::identity::{self, Proposal, Proposals, Revision, RevisionId};
-
use radicle::git::Oid;
-
use radicle::identity::{Identity, Visibility};
-
use radicle::prelude::{Did, Doc};
-
use radicle::storage::ReadStorage as _;
+
use nonempty::NonEmpty;
+
use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
+
use radicle::identity::{doc, Identity, Visibility};
+
use radicle::prelude::{Did, Doc, Id, Signer};
+
use radicle::storage::{ReadStorage as _, WriteRepository};
+
use radicle::{cob, Profile};
use radicle_crypto::Verified;
+
use radicle_surf::diff::Diff;
+
use radicle_term::Element;
+
use serde_json as json;

+
use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{string, Args, Error, Help};
-
use crate::terminal::Element;
+
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::patch::Message;
use crate::terminal::Interactive;

pub const HELP: Help = Help {
    name: "id",
-
    description: "Manage identity documents",
+
    description: "Manage repository identities",
    version: env!("CARGO_PKG_VERSION"),
    usage: r#"
Usage

-
    rad id (update|edit) [--title|-t] [--description|-d]
-
                         [--delegates <did>] [--threshold <num>]
-
                         [--visibility <private | public>]
-
                         [--allow <did>] [--no-confirm] [<option>...]
    rad id list [<option>...]
-
    rad id rebase <id> [--rev <revision-id>] [<option>...]
-
    rad id show <id> [--rev <revision-id>] [--revisions] [<option>...]
-
    rad id (accept|reject|close|commit) <id> [--rev <revision-id>] [--no-confirm] [<option>...]
+
    rad id update [--title <string>] [--description <string>]
+
                  [--delegate <did>] [--rescind <did>]
+
                  [--threshold <num>] [--visibility <private | public>]
+
                  [--allow <did>] [--no-confirm] [--payload <id> <key> <val>...]
+
                  [<option>...]
+
    rad id edit <revision-id> [--title <string>] [--description <string>] [<option>...]
+
    rad id show <revision-id> [<option>...]
+
    rad id <accept | reject | redact> <revision-id> [<option>...]

Options

+
    --repo <rid>           Repository (defaults to the current repository)
    --quiet, -q            Don't print anything
    --help                 Print help
"#,
};

-
#[derive(serde::Deserialize, serde::Serialize, Debug)]
-
pub struct Metadata {
-
    title: String,
-
    description: String,
-
    proposed: Doc<Verified>,
-
}
-

-
impl Metadata {
-
    fn edit(self) -> anyhow::Result<Self> {
-
        let yaml = serde_yaml::to_string(&self)?;
-
        match term::Editor::new().edit(yaml)? {
-
            Some(meta) => Ok(serde_yaml::from_str(&meta).context("failed to parse proposal meta")?),
-
            None => Err(anyhow!("Operation aborted!")),
-
        }
-
    }
-
}
-

#[derive(Clone, Debug, Default)]
pub enum Operation {
-
    Accept {
-
        id: Rev,
-
        rev: Option<RevisionId>,
-
    },
-
    Reject {
-
        id: Rev,
-
        rev: Option<RevisionId>,
-
    },
-
    Edit {
+
    Update {
        title: Option<String>,
        description: Option<String>,
-
        delegates: Vec<Did>,
-
        visibility: Option<Visibility>,
+
        delegate: Vec<Did>,
+
        rescind: Vec<Did>,
        threshold: Option<usize>,
+
        visibility: Option<Visibility>,
+
        payload: Vec<(doc::PayloadId, String, json::Value)>,
    },
-
    Update {
-
        id: Rev,
-
        rev: Option<RevisionId>,
+
    AcceptRevision {
+
        revision: Rev,
+
    },
+
    RejectRevision {
+
        revision: Rev,
+
    },
+
    EditRevision {
+
        revision: Rev,
        title: Option<String>,
        description: Option<String>,
-
        delegates: Vec<Did>,
-
        threshold: Option<usize>,
    },
-
    Rebase {
-
        id: Rev,
-
        rev: Option<RevisionId>,
+
    RedactRevision {
+
        revision: Rev,
    },
-
    Show {
-
        id: Rev,
-
        rev: Option<RevisionId>,
-
        show_revisions: bool,
+
    ShowRevision {
+
        revision: Rev,
    },
    #[default]
-
    List,
-
    Commit {
-
        id: Rev,
-
        rev: Option<RevisionId>,
-
    },
-
    Close {
-
        id: Rev,
-
    },
+
    ListRevisions,
}

#[derive(Default, PartialEq, Eq)]
@@ -107,16 +83,15 @@ pub enum OperationName {
    Reject,
    Edit,
    Update,
-
    Rebase,
    Show,
+
    Redact,
    #[default]
    List,
-
    Commit,
-
    Close,
}

pub struct Options {
    pub op: Operation,
+
    pub rid: Option<Id>,
    pub interactive: Interactive,
    pub quiet: bool,
}
@@ -127,15 +102,16 @@ impl Args for Options {

        let mut parser = lexopt::Parser::from_args(args);
        let mut op: Option<OperationName> = None;
-
        let mut id: Option<Rev> = None;
-
        let mut rev: Option<RevisionId> = None;
+
        let mut revision: Option<Rev> = None;
+
        let mut rid: Option<Id> = None;
        let mut title: Option<String> = None;
        let mut description: Option<String> = None;
-
        let mut delegates: Vec<Did> = Vec::new();
+
        let mut delegate: Vec<Did> = Vec::new();
+
        let mut rescind: Vec<Did> = Vec::new();
        let mut visibility: Option<Visibility> = None;
        let mut threshold: Option<usize> = None;
-
        let mut interactive = Interactive::from(io::stdout().is_terminal());
-
        let mut show_revisions = false;
+
        let mut interactive = Interactive::new(io::stdout());
+
        let mut payload = Vec::new();
        let mut quiet = false;

        while let Some(arg) = parser.next()? {
@@ -143,10 +119,14 @@ impl Args for Options {
                Long("help") | Short('h') => {
                    return Err(Error::Help.into());
                }
-
                Long("title") if op == Some(OperationName::Edit) => {
+
                Long("title")
+
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
+
                {
                    title = Some(parser.value()?.to_string_lossy().into());
                }
-
                Long("description") if op == Some(OperationName::Edit) => {
+
                Long("description")
+
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
+
                {
                    description = Some(parser.value()?.to_string_lossy().into());
                }
                Long("quiet") | Short('q') => {
@@ -158,26 +138,27 @@ impl Args for Options {
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "e" | "edit" => op = Some(OperationName::Edit),
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "rebase" => op = Some(OperationName::Rebase),
                    "l" | "list" => op = Some(OperationName::List),
                    "s" | "show" => op = Some(OperationName::Show),
                    "a" | "accept" => op = Some(OperationName::Accept),
                    "r" | "reject" => op = Some(OperationName::Reject),
-
                    "commit" => op = Some(OperationName::Commit),
-
                    "close" => op = Some(OperationName::Close),
+
                    "d" | "redact" => op = Some(OperationName::Redact),

                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
-
                Long("rev") => {
-
                    let val = String::from(parser.value()?.to_string_lossy());
-
                    rev = Some(
-
                        RevisionId::from_str(&val)
-
                            .map_err(|_| anyhow!("invalid revision '{}'", val))?,
-
                    );
+
                Long("repo") => {
+
                    let val = parser.value()?;
+
                    let val = term::args::rid(&val)?;
+

+
                    rid = Some(val);
+
                }
+
                Long("delegate") => {
+
                    let did = term::args::did(&parser.value()?)?;
+
                    delegate.push(did);
                }
-
                Long("delegates") => {
+
                Long("rescind") => {
                    let did = term::args::did(&parser.value()?)?;
-
                    delegates.push(did);
+
                    rescind.push(did);
                }
                Long("allow") => {
                    let value = parser.value()?;
@@ -197,12 +178,28 @@ impl Args for Options {
                Long("threshold") => {
                    threshold = Some(parser.value()?.to_string_lossy().parse()?);
                }
-
                Long("revisions") => {
-
                    show_revisions = true;
+
                Long("payload") => {
+
                    let mut values = parser.values()?;
+
                    let id = values
+
                        .next()
+
                        .ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
+
                    let id: doc::PayloadId = term::args::parse_value("payload", id)?;
+

+
                    let key = values
+
                        .next()
+
                        .ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
+
                    let key = term::args::string(&key);
+

+
                    let val = values
+
                        .next()
+
                        .ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
+
                    let val = json::from_str(val.to_string_lossy().to_string().as_str())?;
+

+
                    payload.push((id, key, val));
                }
-
                Value(val) if op.is_some() => {
-
                    let val = string(&val);
-
                    id = Some(Rev::from(val));
+
                Value(val) => {
+
                    let val = term::args::rev(&val)?;
+
                    revision = Some(val);
                }
                _ => {
                    return Err(anyhow!(arg.unexpected()));
@@ -211,49 +208,37 @@ impl Args for Options {
        }

        let op = match op.unwrap_or_default() {
-
            OperationName::Accept => Operation::Accept {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
+
            OperationName::Accept => Operation::AcceptRevision {
+
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
            },
-
            OperationName::Reject => Operation::Reject {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
+
            OperationName::Reject => Operation::RejectRevision {
+
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
            },
-
            OperationName::Edit => Operation::Edit {
+
            OperationName::Edit => Operation::EditRevision {
                title,
                description,
-
                delegates,
-
                visibility,
-
                threshold,
+
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
+
            },
+
            OperationName::Show => Operation::ShowRevision {
+
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
+
            },
+
            OperationName::List => Operation::ListRevisions,
+
            OperationName::Redact => Operation::RedactRevision {
+
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
            },
            OperationName::Update => Operation::Update {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
                title,
                description,
-
                delegates,
+
                delegate,
+
                rescind,
                threshold,
-
            },
-
            OperationName::Rebase => Operation::Rebase {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
-
            },
-
            OperationName::Show => Operation::Show {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
-
                show_revisions,
-
            },
-
            OperationName::List => Operation::List,
-
            OperationName::Commit => Operation::Commit {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
-
                rev,
-
            },
-
            OperationName::Close => Operation::Close {
-
                id: id.ok_or_else(|| anyhow!("a proposal must be provided"))?,
+
                visibility,
+
                payload,
            },
        };
        Ok((
            Options {
+
                rid,
                op,
                interactive,
                quiet,
@@ -267,432 +252,394 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let signer = term::signer(&profile)?;
    let storage = &profile.storage;
-
    let (_, id) = radicle::rad::cwd()?;
-
    let repo = storage.repository(id)?;
-
    let mut proposals = Proposals::open(&repo)?;
-
    let previous = Identity::load(signer.public_key(), &repo)?;
+
    let rid = if let Some(rid) = options.rid {
+
        rid
+
    } else {
+
        let (_, rid) = radicle::rad::cwd()?;
+
        rid
+
    };
+
    let repo = storage
+
        .repository(rid)
+
        .context(anyhow!("repository `{rid}` not found in local storage"))?;
+
    let mut identity = Identity::load_mut(&repo)?;
+
    let current = identity.current().clone();

-
    let interactive = &options.interactive;
    match options.op {
-
        Operation::Accept { id, rev } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let (rid, revision) = select(&proposal, rev, &previous, interactive)?;
-
            warn_out_of_date(revision, &previous);
-
            let yes = confirm(interactive, "Are you sure you want to accept?");
+
        Operation::AcceptRevision { revision } => {
+
            let revision = get(revision, &identity, &repo)?.clone();
+
            let id = revision.id;

-
            if yes {
-
                let (_, signature) = revision.proposed.sign(&signer)?;
-
                proposal.accept(rid, signature, &signer)?;
+
            if !revision.is_active() {
+
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
+
            }

-
                if !options.quiet {
-
                    term::success!("Accepted proposal ✓");
-
                    print(&proposal, &previous, None)?;
+
            if options
+
                .interactive
+
                .confirm(format!("Accept revision {}?", term::format::tertiary(id)))
+
            {
+
                identity.accept(&revision, &signer)?;
+

+
                if let Some(revision) = identity.revision(&id) {
+
                    // Update the canonical head to point to the latest accepted revision.
+
                    if revision.is_accepted() && revision.id == identity.current {
+
                        repo.set_identity_head_to(revision.id)?;
+
                    }
+
                    // TODO: Different output if canonical changed?
+

+
                    if !options.quiet {
+
                        term::success!("Revision {id} accepted");
+
                        print_meta(revision, &current, &profile)?;
+
                    }
                }
            }
        }
-
        Operation::Reject { id, rev } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let (rid, revision) = select(&proposal, rev, &previous, interactive)?;
-
            warn_out_of_date(revision, &previous);
-
            let yes = confirm(interactive, "Are you sure you want to reject?");
+
        Operation::RejectRevision { revision } => {
+
            let revision = get(revision, &identity, &repo)?.clone();

-
            if yes {
-
                proposal.reject(rid, &signer)?;
+
            if !revision.is_active() {
+
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
+
            }
+

+
            if options.interactive.confirm(format!(
+
                "Reject revision {}?",
+
                term::format::tertiary(revision.id)
+
            )) {
+
                identity.reject(revision.id, &signer)?;

                if !options.quiet {
-
                    term::success!("Rejected proposal 👎");
-
                    print(&proposal, &previous, None)?;
+
                    term::success!("Revision {} rejected", revision.id);
+
                    print_meta(&revision, &current, &profile)?;
                }
            }
        }
-
        Operation::Edit {
+
        Operation::EditRevision {
+
            revision,
            title,
            description,
-
            delegates,
-
            visibility,
-
            threshold,
        } => {
-
            let proposed = {
-
                let mut proposed = previous.doc.clone();
-
                proposed.threshold = threshold.unwrap_or(proposed.threshold);
-
                proposed.delegates.extend(delegates);
-
                proposed.visibility = visibility.unwrap_or(proposed.visibility);
-
                proposed
-
            };
+
            let revision = get(revision, &identity, &repo)?.clone();

-
            let meta = Metadata {
-
                title: title.unwrap_or("Enter a title".to_owned()),
-
                description: description.unwrap_or("Enter a description".to_owned()),
-
                proposed,
-
            };
-
            let create = if interactive.yes() {
-
                meta.edit()?
-
            } else {
-
                meta
+
            if !revision.is_active() {
+
                anyhow::bail!("revision can no longer be edited");
+
            }
+
            let Some((title, description)) = edit_title_description(title, description)? else {
+
                anyhow::bail!("revision title or description missing");
            };
-
            let proposal = proposals.create(
-
                create.title,
-
                create.description,
-
                previous.current,
-
                create.proposed,
-
                &signer,
-
            )?;
-
            if options.quiet {
-
                term::print(proposal.id);
-
            } else {
-
                term::success!(
-
                    "Identity proposal '{}' created",
-
                    term::format::highlight(proposal.id)
-
                );
-
                print(&proposal, &previous, None)?;
+
            identity.edit(revision.id, title, description, &signer)?;
+

+
            if !options.quiet {
+
                term::success!("Revision {} edited", revision.id);
            }
        }
        Operation::Update {
-
            id,
-
            rev,
            title,
            description,
-
            delegates,
+
            delegate: delegates,
+
            rescind,
            threshold,
+
            visibility,
+
            payload,
        } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let (_, revision) = select(&proposal, rev, &previous, interactive)?;
-

-
            let proposed = {
-
                let mut proposed = revision.proposed.clone();
-
                proposed.threshold = threshold.unwrap_or(revision.proposed.threshold);
-
                proposed.delegates.extend(delegates);
-
                proposed
-
            };
-

-
            let meta = Metadata {
-
                title: title.unwrap_or(proposal.title().to_string()),
-
                description: description.unwrap_or(
+
            let proposal = {
+
                let mut proposal = current.doc.clone();
+
                proposal.threshold = threshold.unwrap_or(proposal.threshold);
+
                proposal.visibility = visibility.unwrap_or(proposal.visibility);
+
                proposal.delegates = NonEmpty::from_vec(
                    proposal
-
                        .description()
-
                        .unwrap_or("Enter a description")
-
                        .to_string(),
-
                ),
-
                proposed,
-
            };
-

-
            let update = if interactive.yes() {
-
                meta.edit()?
-
            } else {
-
                meta
-
            };
-
            warn_out_of_date(revision, &previous);
-
            let yes = confirm(interactive, "Are you sure you want to update?");
-
            if yes {
-
                proposal.edit(update.title, update.description, &signer)?;
-
                let revision = proposal.update(previous.current, update.proposed, &signer)?;
-

-
                if options.quiet {
-
                    term::print(revision.to_string());
-
                } else {
-
                    term::success!(
-
                        "Identity proposal '{}' updated",
-
                        term::format::highlight(proposal.id)
-
                    );
-
                    term::success!(
-
                        "Revision '{}'",
-
                        term::format::highlight(revision.to_string())
-
                    );
-
                    print(&proposal, &previous, None)?;
-
                }
-
            }
-
        }
-
        Operation::Rebase { id, rev } => {
-
            let id = id.resolve(&repo.backend)?;
-
            // TODO: it would be nice if rebasing also handled fast-forwards nicely.
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let (_, revision) = select(&proposal, rev, &previous, interactive)?;
-
            let yes = confirm(interactive, "Are you sure you want to rebase?");
-
            if yes {
-
                let revision =
-
                    proposal.update(previous.current, revision.proposed.clone(), &signer)?;
-

-
                if options.quiet {
-
                    term::print(revision.to_string());
-
                } else {
-
                    term::success!(
-
                        "Identity proposal '{}' rebased",
-
                        term::format::highlight(proposal.id)
-
                    );
-
                    term::success!(
-
                        "Revision '{}'",
-
                        term::format::highlight(revision.to_string())
-
                    );
-
                    print(&proposal, &previous, None)?;
-
                }
-
            }
-
        }
-
        Operation::List => {
-
            let mut t = term::Table::new(term::table::TableOptions::default());
-
            // Sort the list by the latest timestamped revisions (i.e. latest edits)
-
            let mut timestamped = Vec::new();
-
            let mut no_latest = Vec::new();
-
            for result in proposals.all()? {
-
                let (id, proposal) = result?;
-
                match proposal.latest() {
-
                    None => no_latest.push((id, proposal)),
-
                    Some((_, revision)) => {
-
                        timestamped.push(((revision.timestamp, id), id, proposal));
+
                        .delegates
+
                        .into_iter()
+
                        .chain(delegates)
+
                        .filter(|d| !rescind.contains(d))
+
                        .collect::<Vec<_>>(),
+
                )
+
                .ok_or(anyhow!(
+
                    "at lease one delegate must be present for the identity to be valid"
+
                ))?;
+

+
                for (id, key, val) in payload {
+
                    if let Some(ref mut payload) = proposal.payload.get_mut(&id) {
+
                        if let Some(obj) = payload.as_object_mut() {
+
                            obj.insert(key, val);
+
                        } else {
+
                            anyhow::bail!("payload `{id}` is not a map");
+
                        }
+
                    } else {
+
                        anyhow::bail!("payload `{id}` not found in identity document");
                    }
                }
+
                proposal
+
            };
+
            let revision = update(title, description, proposal, &mut identity, &signer)?;
+

+
            if revision.is_accepted() && revision.parent == Some(current.id) {
+
                // Update the canonical head to point to the latest accepted revision.
+
                repo.set_identity_head_to(revision.id)?;
            }
-
            timestamped
-
                .sort_by(|((t1, id1), _, _), ((t2, id2), _, _)| t1.cmp(t2).then(id1.cmp(id2)));
-
            for (id, proposal) in timestamped
-
                .into_iter()
-
                .map(|(_, id, p)| (id, p))
-
                .chain(no_latest.into_iter())
-
            {
-
                let state = match proposal.state() {
-
                    identity::State::Open => term::format::badge_primary("open"),
-
                    identity::State::Closed => term::format::badge_negative("closed"),
-
                    identity::State::Committed => term::format::badge_positive("committed"),
-
                };
-
                t.push([
-
                    term::format::yellow(id.to_string()),
-
                    term::format::italic(format!("{:?}", proposal.title())),
-
                    state,
-
                ]);
+
            if options.quiet {
+
                term::print(revision.id);
+
            } else {
+
                term::success!(
+
                    "Identity revision {} created",
+
                    term::format::tertiary(revision.id)
+
                );
+
                print(&revision, &current, &repo, &profile)?;
            }
-
            t.print();
        }
-
        Operation::Commit { id, rev } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let (rid, revision) = commit_select(&proposal, rev, &previous, interactive)?;
-
            warn_out_of_date(revision, &previous);
-
            let yes = confirm(interactive, "Are you sure you want to commit?");
-

-
            if yes {
-
                let id = Proposal::commit(&proposal, &rid, signer.public_key(), &repo, &signer)?;
-
                proposal.commit(&signer)?;
-

-
                if options.quiet {
-
                    term::print(id.current);
-
                } else {
-
                    term::success!("Committed new identity '{}'", id.current);
-
                    print(&proposal, &previous, None)?;
+
        Operation::ListRevisions => {
+
            let mut revisions =
+
                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());
+

+
            revisions.push([
+
                term::format::dim(String::from("●")).into(),
+
                term::format::bold(String::from("ID")).into(),
+
                term::format::bold(String::from("Title")).into(),
+
                term::format::bold(String::from("Author")).into(),
+
                term::Label::blank(),
+
                term::format::bold(String::from("Status")).into(),
+
                term::format::bold(String::from("Created")).into(),
+
            ]);
+
            revisions.divider();
+

+
            for r in identity.revisions().rev() {
+
                let icon = match r.state {
+
                    identity::State::Active => term::format::tertiary("●"),
+
                    identity::State::Accepted => term::format::positive("●"),
+
                    identity::State::Rejected => term::format::negative("●"),
+
                    identity::State::Stale => term::format::dim("●"),
                }
+
                .into();
+
                let state = r.state.to_string().into();
+
                let id = term::format::oid(r.id).into();
+
                let title = term::label(r.title.to_string());
+
                let (alias, author) =
+
                    term::format::Author::new(r.author.public_key(), &profile).labels();
+
                let timestamp = term::format::timestamp(&r.timestamp).into();
+

+
                revisions.push([icon, id, title, alias, author, state, timestamp]);
            }
+
            revisions.print();
        }
-
        Operation::Close { id } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let mut proposal = proposals.get_mut(&id)?;
-
            let yes = confirm(interactive, "Are you sure you want to close?");
+
        Operation::RedactRevision { revision } => {
+
            let revision = get(revision, &identity, &repo)?.clone();

-
            if yes {
-
                proposal.close(&signer)?;
+
            if revision.is_accepted() {
+
                anyhow::bail!("cannot redact accepted revision");
+
            }
+
            if options.interactive.confirm(format!(
+
                "Redact revision {}?",
+
                term::format::tertiary(revision.id)
+
            )) {
+
                identity.redact(revision.id, &signer)?;

                if !options.quiet {
-
                    term::success!("Closed identity proposal '{}'", id);
-
                    print(&proposal, &previous, None)?;
+
                    term::success!("Revision {} redacted", revision.id);
                }
            }
        }
-
        Operation::Show {
-
            id,
-
            rev,
-
            show_revisions,
-
        } => {
-
            let id = id.resolve(&repo.backend)?;
-
            let proposal = proposals
-
                .get(&id)?
-
                .context("No proposal with the given ID exists")?;
-

-
            print(&proposal, &previous, rev.as_ref())?;
-
            if show_revisions {
-
                term::header("Revisions");
-
                for rid in proposal.revisions().map(|(id, _)| id) {
-
                    println!("{rid}");
-
                }
-
            }
+
        Operation::ShowRevision { revision } => {
+
            let revision = get(revision, &identity, &repo)?;
+
            print(revision, &current, &repo, &profile)?;
        }
    }
    Ok(())
}

-
fn warn_out_of_date(revision: &Revision, previous: &Identity<Oid>) {
-
    if revision.current != previous.current {
-
        term::warning("Revision is out of date");
-
        term::warning(format!("{} =/= {}", revision.current, previous.current));
-
        term::tip!("Consider using 'rad id rebase' to update the proposal to the latest identity");
-
    }
+
fn get<'a>(
+
    revision: Rev,
+
    identity: &'a Identity,
+
    repo: &radicle::storage::git::Repository,
+
) -> anyhow::Result<&'a Revision> {
+
    let id = revision.resolve(&repo.backend)?;
+
    let revision = identity
+
        .revision(&id)
+
        .ok_or(anyhow!("revision `{id}` not found"))?;
+

+
    Ok(revision)
}

-
fn confirm(interactive: &Interactive, msg: &str) -> bool {
-
    if interactive.yes() {
-
        term::confirm(msg)
-
    } else {
-
        true
+
fn print_meta(
+
    revision: &Revision,
+
    previous: &Doc<Verified>,
+
    profile: &Profile,
+
) -> anyhow::Result<()> {
+
    let mut attrs = term::Table::<2, term::Label>::new(Default::default());
+

+
    attrs.push([
+
        term::format::bold("Title").into(),
+
        term::label(revision.title.to_owned()),
+
    ]);
+
    attrs.push([
+
        term::format::bold("Revision").into(),
+
        term::label(revision.id.to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::bold("Blob").into(),
+
        term::label(revision.blob.to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::bold("Author").into(),
+
        term::label(revision.author.to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::bold("State").into(),
+
        term::label(revision.state.to_string()),
+
    ]);
+
    attrs.push([
+
        term::format::bold("Quorum").into(),
+
        if revision.is_accepted() {
+
            term::format::positive("yes").into()
+
        } else {
+
            term::format::negative("no").into()
+
        },
+
    ]);
+

+
    let mut meta = term::VStack::default()
+
        .border(Some(term::colors::FAINT))
+
        .child(attrs)
+
        .children(if !revision.description.is_empty() {
+
            vec![
+
                term::Label::blank().boxed(),
+
                term::textarea(revision.description.to_owned()).boxed(),
+
            ]
+
        } else {
+
            vec![]
+
        })
+
        .divider();
+

+
    let accepted = revision.accepted().collect::<Vec<_>>();
+
    let rejected = revision.rejected().collect::<Vec<_>>();
+
    let unknown = previous
+
        .delegates
+
        .iter()
+
        .filter(|id| !accepted.contains(id) && !rejected.contains(id))
+
        .collect::<Vec<_>>();
+
    let mut signatures = term::Table::<4, _>::default();
+

+
    for id in accepted {
+
        let author = term::format::Author::new(&id, profile);
+
        signatures.push([
+
            term::format::positive("✓").into(),
+
            id.to_string().into(),
+
            author.alias().unwrap_or_default(),
+
            author.you().unwrap_or_default(),
+
        ]);
    }
-
}
-

-
fn select<'a>(
-
    proposal: &'a Proposal,
-
    id: Option<RevisionId>,
-
    previous: &Identity<Oid>,
-
    interactive: &Interactive,
-
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
-
    let (id, revision) = match (id, interactive) {
-
        (None, Interactive::Yes) => {
-
            let (id, revision) = term::proposal::revision_select(proposal).unwrap();
-
            (*id, revision)
-
        }
-
        (None, Interactive::No) => {
-
            let (id, revision) = proposal
-
                .revisions()
-
                .next()
-
                .ok_or(anyhow!("No revisions found!"))?;
-
            (*id, revision)
-
        }
-
        (Some(id), _) => {
-
            let revision = proposal
-
                .revision(&id)
-
                .context(format!("No revision found for {id}"))?
-
                .as_ref()
-
                .context(format!("Revision {id} was redacted"))?;
-
            (id, revision)
-
        }
-
    };
-
    if interactive.yes() {
-
        print_revision(revision, previous)?;
+
    for id in rejected {
+
        let author = term::format::Author::new(&id, profile);
+
        signatures.push([
+
            term::format::negative("✗").into(),
+
            id.to_string().into(),
+
            author.alias().unwrap_or_default(),
+
            author.you().unwrap_or_default(),
+
        ]);
    }
-
    Ok((id, revision))
-
}
-

-
fn commit_select<'a>(
-
    proposal: &'a Proposal,
-
    id: Option<RevisionId>,
-
    previous: &'a Identity<Oid>,
-
    interactive: &Interactive,
-
) -> anyhow::Result<(RevisionId, &'a identity::Revision)> {
-
    let (id, revision) = match (id, interactive) {
-
        (None, Interactive::Yes) => {
-
            let (id, revision) =
-
                term::proposal::revision_commit_select(proposal, previous).unwrap();
-
            (*id, revision)
-
        }
-
        (None, Interactive::No) => {
-
            let (id, revision) = proposal
-
                .revisions()
-
                .find(|(_, r)| r.is_quorum_reached(previous))
-
                .ok_or(anyhow!("No revisions with quorum found"))?;
-
            (*id, revision)
-
        }
-
        (Some(id), _) => {
-
            let revision = proposal
-
                .revision(&id)
-
                .context(format!("No revision found for {id}"))?
-
                .as_ref()
-
                .context(format!("Revision {id} was redacted"))?;
-
            (id, revision)
-
        }
-
    };
-
    if interactive.yes() {
-
        print_revision(revision, previous)?;
+
    for id in unknown {
+
        let author = term::format::Author::new(id, profile);
+
        signatures.push([
+
            term::format::dim("?").into(),
+
            id.to_string().into(),
+
            author.alias().unwrap_or_default(),
+
            author.you().unwrap_or_default(),
+
        ]);
    }
-
    Ok((id, revision))
-
}
+
    meta.push(signatures);
+
    meta.print();

-
fn print_meta(title: &str, description: Option<&str>, state: &identity::State) {
-
    term::info!("{}: {}", term::format::bold("title"), title);
-
    term::info!(
-
        "{}: {}",
-
        term::format::bold("description"),
-
        description.unwrap_or("No description provided")
-
    );
-
    term::info!(
-
        "{}: {}",
-
        term::format::bold("status"),
-
        match state {
-
            identity::State::Open => term::format::badge_primary("open"),
-
            identity::State::Closed => term::format::badge_negative("closed"),
-
            identity::State::Committed => term::format::badge_positive("committed"),
-
        }
-
    );
+
    Ok(())
}

-
fn print_revision(revision: &identity::Revision, previous: &Identity<Oid>) -> anyhow::Result<()> {
-
    term::info!("{}: {}", term::format::bold("author"), revision.author.id());
-

-
    term::header("Document Diff");
-
    print!("{}", term::proposal::diff(revision, previous)?);
-
    term::blank();
-

-
    {
-
        term::header("Accepted");
-
        let accepted = revision.accepted();
-
        let total = accepted.len();
-
        print!(
-
            "{}",
-
            term::format::positive(format!(
-
                "{}: {}\n{}: {}",
-
                "total",
-
                total,
-
                "keys",
-
                serde_json::to_string_pretty(&accepted)?
-
            ))
-
        );
-
        term::blank();
-
    }
+
fn print(
+
    revision: &identity::Revision,
+
    previous: &identity::Revision,
+
    repo: &radicle::storage::git::Repository,
+
    profile: &Profile,
+
) -> anyhow::Result<()> {
+
    print_meta(revision, previous, profile)?;
+
    println!();
+
    print_diff(revision.parent.as_ref(), &revision.id, repo)?;

-
    {
-
        term::header("Rejected");
-
        let rejected = revision.rejected();
-
        let total = rejected.len();
-
        print!(
-
            "{}",
-
            term::format::negative(format!(
-
                "{}: {}\n{}: {}",
-
                "total",
-
                total,
-
                "keys",
-
                serde_json::to_string_pretty(&rejected)?
-
            ))
-
        );
-
        term::blank();
-
    }
+
    Ok(())
+
}

-
    term::header("Quorum Reached");
-
    print!(
-
        "{}",
-
        if revision.is_quorum_reached(previous) {
-
            term::format::positive("👍 yes")
+
fn edit_title_description(
+
    title: Option<String>,
+
    description: Option<String>,
+
) -> anyhow::Result<Option<(String, String)>> {
+
    const HELP: &str = r#"<!--
+
Please enter a patch message for your changes. An empty
+
message aborts the patch proposal.
+

+
The first line is the patch title. The patch description
+
follows, and must be separated with a blank line, just
+
like a commit message. Markdown is supported in the title
+
and description.
+
-->"#;
+

+
    let result = if let (Some(t), Some(d)) = (title.as_ref(), description.as_ref()) {
+
        Some((t.to_owned(), d.to_owned()))
+
    } else {
+
        let result = Message::edit_title_description(title, description, HELP)?;
+
        if let Some((title, description)) = result {
+
            Some((title, description))
        } else {
-
            term::format::negative("👎 no")
+
            None
        }
-
    );
-
    term::blank();
+
    };
+
    Ok(result)
+
}

-
    Ok(())
+
fn update<R: WriteRepository + cob::Store, G: Signer>(
+
    title: Option<String>,
+
    description: Option<String>,
+
    doc: Doc<Verified>,
+
    current: &mut IdentityMut<R>,
+
    signer: &G,
+
) -> anyhow::Result<Revision> {
+
    if let Some((title, description)) = edit_title_description(title, description)? {
+
        let revision = current.update(title, description, &doc, signer)?;
+
        Ok(revision)
+
    } else {
+
        Err(anyhow!("you must provide a revision title and description"))
+
    }
}

-
fn print(
-
    proposal: &identity::Proposal,
-
    previous: &Identity<Oid>,
-
    rid: Option<&RevisionId>,
+
fn print_diff(
+
    previous: Option<&RevisionId>,
+
    current: &RevisionId,
+
    repo: &radicle::storage::git::Repository,
) -> anyhow::Result<()> {
-
    let revision = match rid {
-
        None => {
-
            proposal
-
                .latest()
-
                .context("No latest proposal revision to show")?
-
                .1
-
        }
-
        Some(rid) => proposal
-
            .revision(rid)
-
            .context(format!("No revision found for {rid}"))?
-
            .as_ref()
-
            .context(format!("Revision {rid} was redacted"))?,
+
    let previous = if let Some(previous) = previous {
+
        let previous = Doc::<Verified>::load_at(*previous, repo)?;
+
        let previous = serde_json::to_string_pretty(&previous.doc)?;
+

+
        Some(previous)
+
    } else {
+
        None
    };
-
    print_meta(proposal.title(), proposal.description(), proposal.state());
-
    print_revision(revision, previous)
+
    let current = Doc::<Verified>::load_at(*current, repo)?;
+
    let current = serde_json::to_string_pretty(&current.doc)?;
+

+
    let tmp = tempfile::tempdir()?;
+
    let repo = radicle::git::raw::Repository::init_bare(tmp.path())?;
+

+
    let previous = if let Some(previous) = previous {
+
        let tree = radicle::git::write_tree(&doc::PATH, previous.as_bytes(), &repo)?;
+
        Some(tree)
+
    } else {
+
        None
+
    };
+
    let current = radicle::git::write_tree(&doc::PATH, current.as_bytes(), &repo)?;
+
    let mut opts = radicle::git::raw::DiffOptions::new();
+
    opts.context_lines(u32::MAX);
+

+
    let diff = repo.diff_tree_to_tree(previous.as_ref(), Some(&current), Some(&mut opts))?;
+
    let diff = Diff::try_from(diff)?;
+

+
    if let Some(modified) = diff.modified().next() {
+
        let diff = modified.diff.to_unified_string()?;
+
        print!("{diff}");
+
    } else {
+
        term::print(term::format::italic("No changes."));
+
    }
+
    Ok(())
}
modified radicle-cli/src/commands/inspect.rs
@@ -8,9 +8,8 @@ use anyhow::{anyhow, Context as _};
use chrono::prelude::*;
use json_color::{Color, Colorizer};

-
use radicle::crypto::{Unverified, Verified};
-
use radicle::identity::Untrusted;
-
use radicle::identity::{Doc, Id};
+
use radicle::identity::Id;
+
use radicle::identity::Identity;
use radicle::node::tracking::Policy;
use radicle::node::AliasStore as _;
use radicle::storage::{ReadRepository, ReadStorage};
@@ -142,7 +141,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let repo = storage
        .repository(rid)
        .context("No project with the given RID exists")?;
-
    let project = Doc::<Verified>::canonical(&repo)?;
+
    let project = repo.identity_doc()?;

    match options.target {
        Target::Refs => {
@@ -197,14 +196,21 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            println!("{}", term::format::visibility(&project.doc.visibility));
        }
        Target::History => {
-
            let head = Doc::<Untrusted>::head(signer.public_key(), &repo)?;
+
            let identity = Identity::load(&repo)?;
+
            let head = repo.identity_head_of(signer.public_key())?;
            let history = repo.revwalk(head)?;

            for oid in history {
                let oid = oid?.into();
                let tip = repo.commit(oid)?;
-
                let blob = Doc::<Unverified>::blob_at(oid, &repo)?;
-
                let content: serde_json::Value = serde_json::from_slice(blob.content())?;
+

+
                let Some(revision) = identity.revision(&tip.id().into()) else {
+
                    continue;
+
                };
+
                if !revision.is_accepted() {
+
                    continue;
+
                }
+
                let doc = &revision.doc;
                let timezone = if tip.time().sign() == '+' {
                    #[allow(deprecated)]
                    FixedOffset::east(tip.time().offset_minutes() * 60)
@@ -227,7 +233,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                if let Ok(parent) = tip.parent_id(0) {
                    println!("parent {parent}");
                }
-
                println!("blob   {}", blob.id());
+
                println!("blob   {}", revision.blob);
                println!("date   {time}");
                println!();

@@ -242,8 +248,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    term::blank();
                }

-
                let json =
-
                    colorizer().colorize_json_str(&serde_json::to_string_pretty(&content)?)?;
+
                let json = colorizer().colorize_json_str(&serde_json::to_string_pretty(&doc)?)?;
                for line in json.lines() {
                    println!(" {line}");
                }
modified radicle-cli/src/commands/ls.rs
@@ -93,7 +93,6 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        if !doc.visibility.is_public() && !options.private && options.public {
            continue;
        }
-
        let doc = doc.verified()?;
        let proj = doc.project()?;
        let head = term::format::oid(head).into();

modified radicle-cli/src/commands/publish.rs
@@ -2,7 +2,7 @@ use std::ffi::OsString;

use anyhow::{anyhow, Context as _};

-
use radicle::identity::Visibility;
+
use radicle::identity::{Identity, Visibility};
use radicle::node::Handle as _;
use radicle::prelude::Id;
use radicle::storage::{ReadRepository, SignRepository, WriteRepository, WriteStorage};
@@ -76,8 +76,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    };

    let repo = profile.storage.repository_mut(rid)?;
-
    let (_, doc) = repo.identity_doc()?;
-
    let mut doc = doc.verified()?;
+
    let mut identity = Identity::load_mut(&repo)?;
+
    let mut doc = identity.doc().clone();

    if doc.visibility.is_public() {
        return Err(Error::WithHint {
@@ -102,14 +102,8 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    // Update identity document.
    doc.visibility = Visibility::Public;
-
    doc.sign(&signer).and_then(|(_, sig)| {
-
        doc.update(
-
            profile.id(),
-
            "Publish repository",
-
            &[(signer.public_key(), sig)],
-
            repo.raw(),
-
        )
-
    })?;
+

+
    identity.update("Publish repository", "", &doc, &signer)?;
    repo.sign_refs(&signer)?;
    repo.set_identity_head()?;
    repo.validate()?;
modified radicle-cli/src/commands/review.rs
@@ -204,7 +204,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let signer = term::signer(&profile)?;
    let repository = profile.storage.repository(id)?;
    let _project = repository
-
        .identity_doc_of(profile.id())
+
        .identity_doc()
        .context(format!("couldn't load project {id} from local state"))?;
    let mut patches = Patches::open(&repository)?;

modified radicle-cli/src/commands/sync.rs
@@ -256,7 +256,7 @@ fn announce_refs(
    profile: &Profile,
) -> anyhow::Result<()> {
    let repo = profile.storage.repository(rid)?;
-
    let (_, doc) = repo.identity_doc()?;
+
    let doc = repo.identity_doc()?;
    let connected: Vec<_> = if doc.visibility.is_public() {
        let seeds = node.seeds(rid)?;
        seeds.connected().map(|s| s.nid).collect()
modified radicle-cli/src/main.rs
@@ -132,13 +132,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "delegate" => {
-
            term::run_command_args::<rad_delegate::Options, _>(
-
                rad_delegate::HELP,
-
                rad_delegate::run,
-
                args.to_vec(),
-
            );
-
        }
        "diff" => {
            term::run_command_args::<rad_diff::Options, _>(
                rad_diff::HELP,
@@ -146,13 +139,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "edit" => {
-
            term::run_command_args::<rad_edit::Options, _>(
-
                rad_edit::HELP,
-
                rad_edit::run,
-
                args.to_vec(),
-
            );
-
        }
        "fork" => {
            term::run_command_args::<rad_fork::Options, _>(
                rad_fork::HELP,
modified radicle-cli/src/terminal.rs
@@ -2,7 +2,7 @@ pub mod args;
pub use args::{Args, Error, Help};
pub mod format;
pub mod io;
-
pub use io::{proposal, signer};
+
pub use io::signer;
pub mod comment;
pub mod highlight;
pub mod issue;
modified radicle-cli/src/terminal/format.rs
@@ -142,6 +142,18 @@ impl<'a> Author<'a> {
        }
    }

+
    pub fn alias(&self) -> Option<term::Label> {
+
        self.alias.as_ref().map(|a| a.to_string().into())
+
    }
+

+
    pub fn you(&self) -> Option<term::Label> {
+
        if self.you {
+
            Some(term::format::primary("(you)").dim().italic().into())
+
        } else {
+
            None
+
        }
+
    }
+

    pub fn labels(self) -> (term::Label, term::Label) {
        let alias = match &self.alias {
            Some(alias) => term::format::primary(alias).into(),
@@ -149,8 +161,8 @@ impl<'a> Author<'a> {
                .dim()
                .into(),
        };
-
        if self.you {
-
            (alias, term::format::primary("(you)").dim().italic().into())
+
        if let Some(you) = self.you() {
+
            (alias, you)
        } else {
            (
                alias,
modified radicle-cli/src/terminal/io.rs
@@ -67,71 +67,3 @@ pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> {

    comments.get(selection).copied()
}
-

-
pub mod proposal {
-
    use std::fmt::Write as _;
-

-
    use radicle::{
-
        cob::identity::{self, Proposal},
-
        git::Oid,
-
        identity::Identity,
-
    };
-

-
    use super::*;
-
    use crate::terminal::format;
-

-
    pub fn revision_select(
-
        proposal: &Proposal,
-
    ) -> Option<(&identity::RevisionId, &identity::Revision)> {
-
        let revisions = proposal.revisions().collect::<Vec<_>>();
-
        let selection = Select::new(
-
            "Which revision do you want to select?",
-
            (0..revisions.len()).collect(),
-
        )
-
        .with_vim_mode(true)
-
        .with_formatter(&|ix| revisions[ix.index].0.to_string())
-
        .with_render_config(*CONFIG)
-
        .prompt()
-
        .ok()?;
-

-
        revisions.get(selection).copied()
-
    }
-

-
    pub fn revision_commit_select<'a>(
-
        proposal: &'a Proposal,
-
        previous: &'a Identity<Oid>,
-
    ) -> Option<(&'a identity::RevisionId, &'a identity::Revision)> {
-
        let revisions = proposal
-
            .revisions()
-
            .filter(|(_, r)| r.is_quorum_reached(previous))
-
            .collect::<Vec<_>>();
-
        let selection = Select::new(
-
            "Which revision do you want to commit?",
-
            (0..revisions.len()).collect(),
-
        )
-
        .with_formatter(&|ix| revisions[ix.index].0.to_string())
-
        .with_render_config(*CONFIG)
-
        .prompt()
-
        .ok()?;
-

-
        revisions.get(selection).copied()
-
    }
-

-
    pub fn diff(proposal: &identity::Revision, previous: &Identity<Oid>) -> anyhow::Result<String> {
-
        use similar::{ChangeTag, TextDiff};
-

-
        let new = serde_json::to_string_pretty(&proposal.proposed)?;
-
        let previous = serde_json::to_string_pretty(&previous.doc)?;
-
        let diff = TextDiff::from_lines(&previous, &new);
-
        let mut buf = String::new();
-
        for change in diff.iter_all_changes() {
-
            match change.tag() {
-
                ChangeTag::Delete => write!(buf, "{}", format::negative(format!("-{change}")))?,
-
                ChangeTag::Insert => write!(buf, "{}", format::positive(format!("+{change}")))?,
-
                ChangeTag::Equal => write!(buf, " {change}")?,
-
            };
-
        }
-

-
        Ok(buf)
-
    }
-
}
modified radicle-cli/src/terminal/issue.rs
@@ -35,29 +35,7 @@ pub fn get_title_description(
    title: Option<String>,
    description: Option<String>,
) -> io::Result<Option<(String, String)>> {
-
    let mut placeholder = String::new();
-

-
    if let Some(title) = title {
-
        placeholder.push_str(title.trim());
-
        placeholder.push('\n');
-
    }
-
    if let Some(description) = description {
-
        placeholder.push('\n');
-
        placeholder.push_str(description.trim());
-
        placeholder.push('\n');
-
    }
-
    placeholder.push_str(OPEN_MSG);
-

-
    let output = term::patch::Message::Edit.get(&placeholder)?;
-
    let Some((title, description)) = output.split_once("\n\n") else {
-
        return Ok(None);
-
    };
-
    let (title, description) = (title.trim(), description.trim());
-

-
    if title.is_empty() {
-
        return Ok(None);
-
    }
-
    Ok(Some((title.to_owned(), description.to_owned())))
+
    term::patch::Message::edit_title_description(title, description, OPEN_MSG)
}

pub fn show(
modified radicle-cli/src/terminal/patch.rs
@@ -56,6 +56,38 @@ impl Message {
        Ok(comment.to_owned())
    }

+
    /// Open the editor with the given title and description (if any).
+
    /// Returns the edited title and description, or nothing if it couldn't be parsed.
+
    pub fn edit_title_description(
+
        title: Option<String>,
+
        description: Option<String>,
+
        help: &str,
+
    ) -> std::io::Result<Option<(String, String)>> {
+
        let mut placeholder = String::new();
+

+
        if let Some(title) = title {
+
            placeholder.push_str(title.trim());
+
            placeholder.push('\n');
+
        }
+
        if let Some(description) = description {
+
            placeholder.push('\n');
+
            placeholder.push_str(description.trim());
+
            placeholder.push('\n');
+
        }
+
        placeholder.push_str(help);
+

+
        let output = Self::Edit.get(&placeholder)?;
+
        let Some((title, description)) = output.split_once("\n\n") else {
+
            return Ok(None);
+
        };
+
        let (title, description) = (title.trim(), description.trim());
+

+
        if title.is_empty() {
+
            return Ok(None);
+
        }
+
        Ok(Some((title.to_owned(), description.to_owned())))
+
    }
+

    pub fn append(&mut self, arg: &str) {
        if let Message::Text(v) = self {
            v.extend(["\n\n", arg]);
modified radicle-cli/tests/commands.rs
@@ -16,10 +16,9 @@ use radicle::test::fixtures;
use radicle_cli_test::TestFormula;
use radicle_node::service::tracking::{Policy, Scope};
use radicle_node::service::Event;
-
use radicle_node::test::{
-
    environment::{Config, Environment},
-
    logger,
-
};
+
use radicle_node::test::environment::{Config, Environment};
+
#[allow(unused_imports)]
+
use radicle_node::test::logger;

/// Seed used in tests.
const RAD_SEED: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
@@ -230,7 +229,7 @@ fn rad_checkout() {
}

#[test]
-
fn rad_delegate() {
+
fn rad_id() {
    let mut environment = Environment::new();
    let profile = environment.profile("alice");
    let working = tempfile::tempdir().unwrap();
@@ -240,41 +239,113 @@ fn rad_delegate() {
    fixtures::repository(working.path());

    test("examples/rad-init.md", working.path(), Some(home), []).unwrap();
-
    test("examples/rad-delegate.md", working.path(), Some(home), []).unwrap();
+
    test("examples/rad-id.md", working.path(), Some(home), []).unwrap();
}

#[test]
-
fn rad_id() {
+
fn rad_id_multi_delegate() {
    let mut environment = Environment::new();
-
    let profile = environment.profile("alice");
+
    let alice = environment.node(Config::test(Alias::new("alice")));
+
    let bob = environment.node(Config::test(Alias::new("bob")));
    let working = tempfile::tempdir().unwrap();
-
    let home = &profile.home;
+
    let working = working.path();
+
    let acme = Id::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();

    // Setup a test repository.
-
    fixtures::repository(working.path());
+
    fixtures::repository(working.join("alice"));

-
    test("examples/rad-init.md", working.path(), Some(home), []).unwrap();
-
    test("examples/rad-id.md", working.path(), Some(home), []).unwrap();
+
    test(
+
        "examples/rad-init.md",
+
        working.join("alice"),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+

+
    bob.handle.track_repo(acme, Scope::All).unwrap();
+
    alice.connect(&bob).converge([&bob]);
+

+
    test(
+
        "examples/rad-clone.md",
+
        working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    // TODO: Have formula with two connected nodes and a tracked project.
+

+
    formula(&environment.tmp(), "examples/rad-id-multi-delegate.md")
+
        .unwrap()
+
        .home(
+
            "alice",
+
            working.join("alice"),
+
            [("RAD_HOME", alice.home.path().display())],
+
        )
+
        .home(
+
            "bob",
+
            working.join("bob"),
+
            [("RAD_HOME", bob.home.path().display())],
+
        )
+
        .run()
+
        .unwrap();
}

#[test]
-
fn rad_id_rebase() {
+
fn rad_id_conflict() {
    let mut environment = Environment::new();
-
    let profile = environment.profile("alice");
+
    let alice = environment.node(Config::test(Alias::new("alice")));
+
    let bob = environment.node(Config::test(Alias::new("bob")));
    let working = tempfile::tempdir().unwrap();
-
    let home = &profile.home;
+
    let working = working.path();
+
    let acme = Id::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();

    // Setup a test repository.
-
    fixtures::repository(working.path());
+
    fixtures::repository(working.join("alice"));

-
    test("examples/rad-init.md", working.path(), Some(home), []).unwrap();
-
    test("examples/rad-id-rebase.md", working.path(), Some(home), []).unwrap();
+
    test(
+
        "examples/rad-init.md",
+
        working.join("alice"),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    let mut alice = alice.spawn();
+
    let mut bob = bob.spawn();
+

+
    bob.handle.track_repo(acme, Scope::All).unwrap();
+
    alice.connect(&bob).converge([&bob]);
+

+
    test(
+
        "examples/rad-clone.md",
+
        working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+

+
    formula(&environment.tmp(), "examples/rad-id-conflict.md")
+
        .unwrap()
+
        .home(
+
            "alice",
+
            working.join("alice"),
+
            [("RAD_HOME", alice.home.path().display())],
+
        )
+
        .home(
+
            "bob",
+
            working.join("bob"),
+
            [("RAD_HOME", bob.home.path().display())],
+
        )
+
        .run()
+
        .unwrap();
}

#[test]
fn rad_node_connect() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -300,8 +371,6 @@ fn rad_node_connect() {

#[test]
fn rad_node() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let working = tempfile::tempdir().unwrap();
@@ -408,8 +477,6 @@ fn rad_patch_draft() {

#[test]
fn rad_patch_via_push() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let profile = environment.profile("alice");
    let working = tempfile::tempdir().unwrap();
@@ -431,8 +498,6 @@ fn rad_patch_via_push() {
#[test]
#[cfg(not(target_os = "macos"))]
fn rad_review_by_hunk() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let profile = environment.profile("alice");
    let working = tempfile::tempdir().unwrap();
@@ -483,8 +548,6 @@ fn rad_track() {

#[test]
fn rad_clone() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let mut alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -505,8 +568,6 @@ fn rad_clone() {

#[test]
fn rad_clone_all() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let mut alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -658,8 +719,6 @@ fn rad_self() {

#[test]
fn rad_clone_unknown() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let working = environment.tmp().join("working");
@@ -677,8 +736,6 @@ fn rad_clone_unknown() {

#[test]
fn rad_init_sync_and_clone() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -795,8 +852,6 @@ fn rad_fork() {
#[test]
// User tries to clone; no seeds are available, but user has the repo locally.
fn test_clone_without_seeds() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let mut alice = environment.node(Config::test(Alias::new("alice")));
    let working = environment.tmp().join("working");
@@ -818,8 +873,6 @@ fn test_clone_without_seeds() {

#[test]
fn test_cob_replication() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let working = tempfile::tempdir().unwrap();
    let mut alice = environment.node(Config::test(Alias::new("alice")));
@@ -934,8 +987,6 @@ fn test_cob_deletion() {

#[test]
fn rad_sync() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let working = environment.tmp().join("working");
    let alice = environment.node(Config::test(Alias::new("alice")));
@@ -1108,8 +1159,6 @@ fn rad_remote() {

#[test]
fn rad_merge_via_push() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let working = environment.tmp().join("working");
@@ -1189,8 +1238,6 @@ fn rad_merge_no_ff() {

#[test]
fn rad_patch_pull_update() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -1238,8 +1285,6 @@ fn rad_init_private() {

#[test]
fn rad_init_private_clone() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -1303,8 +1348,6 @@ fn rad_publish() {

#[test]
fn framework_home() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -1327,8 +1370,6 @@ fn framework_home() {

#[test]
fn git_push_diverge() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
@@ -1375,8 +1416,6 @@ fn git_push_diverge() {

#[test]
fn git_push_and_pull() {
-
    logger::init(log::Level::Debug);
-

    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
    let bob = environment.node(Config::test(Alias::new("bob")));
modified radicle-cob/src/backend/git/change.rs
@@ -2,7 +2,6 @@

use std::collections::BTreeMap;
use std::convert::TryFrom;
-
use std::iter;
use std::path::PathBuf;

use git_ext::author::Author;
@@ -95,7 +94,7 @@ impl change::Storage for git2::Repository {

    fn store<Signer>(
        &self,
-
        resource: Self::Parent,
+
        resource: Option<Self::Parent>,
        mut parents: Vec<Self::Parent>,
        signer: &Signer,
        spec: store::Template<Self::ObjectId>,
@@ -125,15 +124,16 @@ impl change::Storage for git2::Repository {

        let (id, timestamp) = write_commit(
            self,
-
            *resource,
+
            resource.map(|o| *o),
            // Commit to tips, extra parents and resource.
            tips.iter()
                .cloned()
                .chain(parents.clone())
-
                .chain(iter::once(resource))
+
                .chain(resource)
                .map(git2::Oid::from),
            message,
            signature.clone(),
+
            vec![],
            tree,
        )?;

@@ -164,7 +164,7 @@ impl change::Storage for git2::Repository {
        let parents = commit
            .parents()
            .map(Oid::from)
-
            .filter(|p| *p != resource)
+
            .filter(|p| Some(*p) != resource)
            .collect();
        let mut signatures = Signatures::try_from(&commit)?
            .into_iter()
@@ -195,19 +195,17 @@ impl change::Storage for git2::Repository {

fn parse_resource_trailer<'a>(
    trailers: impl Iterator<Item = &'a OwnedTrailer>,
-
) -> Result<Oid, error::Load> {
+
) -> Result<Option<Oid>, error::Load> {
    for trailer in trailers {
        match trailers::ResourceCommitTrailer::try_from(trailer) {
            Err(trailers::error::InvalidResourceTrailer::WrongToken) => {
                continue;
            }
            Err(err) => return Err(err.into()),
-
            Ok(resource) => return Ok(resource.oid().into()),
+
            Ok(resource) => return Ok(Some(resource.oid().into())),
        }
    }
-
    Err(error::Load::from(
-
        trailers::error::InvalidResourceTrailer::NoTrailer,
-
    ))
+
    Ok(None)
}

fn load_manifest(
@@ -253,13 +251,17 @@ fn load_contents(repo: &git2::Repository, tree: &git2::Tree) -> Result<Contents,

fn write_commit(
    repo: &git2::Repository,
-
    resource: git2::Oid,
+
    resource: Option<git2::Oid>,
    parents: impl IntoIterator<Item = git2::Oid>,
    message: String,
    signature: ExtendedSignature,
+
    trailers: impl IntoIterator<Item = OwnedTrailer>,
    tree: git2::Tree,
) -> Result<(Oid, Timestamp), error::Create> {
-
    let trailers: Vec<OwnedTrailer> = vec![trailers::ResourceCommitTrailer::from(resource).into()];
+
    let trailers: Vec<OwnedTrailer> = trailers
+
        .into_iter()
+
        .chain(resource.map(|r| trailers::ResourceCommitTrailer::from(r).into()))
+
        .collect();
    let author = repo.signature()?;
    #[allow(unused_variables)]
    let timestamp = author.when().seconds();
modified radicle-cob/src/change/store.rs
@@ -21,7 +21,7 @@ pub trait Storage {
    #[allow(clippy::type_complexity)]
    fn store<G>(
        &self,
-
        resource: Self::Parent,
+
        resource: Option<Self::Parent>,
        parents: Vec<Self::Parent>,
        signer: &G,
        template: Template<Self::ObjectId>,
@@ -61,22 +61,22 @@ pub type EntryId = Oid;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry<Resource, Id, Signature> {
-
    /// The content address of the `Change` itself.
+
    /// The content address of the entry itself.
    pub id: Id,
-
    /// The content address of the tree of the `Change`.
+
    /// The content address of the tree of the entry.
    pub revision: Id,
    /// The cryptographic signature(s) and their public keys of the
    /// authors.
    pub signature: Signature,
    /// The parent resource that this change lives under. For example,
    /// this change could be for a patch of a project.
-
    pub resource: Resource,
+
    pub resource: Option<Resource>,
    /// Other parents this change depends on.
    pub parents: Vec<Resource>,
    /// The manifest describing the type of object as well as the type
-
    /// of history for this `Change`.
+
    /// of history for this entry.
    pub manifest: Manifest,
-
    /// The contents that describe `Change`.
+
    /// The contents that describe entry.
    pub contents: Contents,
    /// Timestamp of change.
    pub timestamp: Timestamp,
@@ -87,7 +87,7 @@ where
    Id: fmt::Display,
{
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        write!(f, "Change {{ id: {} }}", self.id)
+
        write!(f, "Entry {{ id: {} }}", self.id)
    }
}

@@ -104,8 +104,8 @@ impl<Resource, Id, Signatures> Entry<Resource, Id, Signatures> {
        &self.contents
    }

-
    pub fn resource(&self) -> &Resource {
-
        &self.resource
+
    pub fn resource(&self) -> Option<&Resource> {
+
        self.resource.as_ref()
    }
}

@@ -142,20 +142,12 @@ pub struct Manifest {
    /// Version number.
    #[serde(default)]
    pub version: Version,
-

-
    /// History type (deprecated).
-
    #[serde(alias = "history_type")]
-
    _history_type: Option<String>,
}

impl Manifest {
    /// Create a new manifest.
    pub fn new(type_name: TypeName, version: Version) -> Self {
-
        Self {
-
            type_name,
-
            version,
-
            _history_type: None,
-
        }
+
        Self { type_name, version }
    }
}

modified radicle-cob/src/change_graph.rs
@@ -163,7 +163,7 @@ impl GraphBuilder {
    where
        S: change::Storage<ObjectId = Oid, Parent = Oid, Signatures = ExtendedSignature>,
    {
-
        let resource_commit = *change.resource();
+
        let resource = change.resource().copied();

        if !self.graph.contains(&commit_id) {
            self.graph.node(commit_id, change);
@@ -173,7 +173,7 @@ impl GraphBuilder {
            .parents_of(&commit_id)?
            .into_iter()
            .filter_map(move |parent| {
-
                if parent != resource_commit && !self.graph.has_dependency(&commit_id, &parent) {
+
                if Some(parent) != resource && !self.graph.has_dependency(&commit_id, &parent) {
                    Some((parent, commit_id))
                } else {
                    None
modified radicle-cob/src/object/collaboration/create.rs
@@ -56,7 +56,7 @@ impl Create {
pub fn create<T, S, G>(
    storage: &S,
    signer: &G,
-
    resource: Oid,
+
    resource: Option<Oid>,
    parents: Vec<Oid>,
    identifier: &PublicKey,
    args: Create,
modified radicle-cob/src/object/collaboration/update.rs
@@ -58,7 +58,7 @@ pub struct Update {
pub fn update<T, S, G>(
    storage: &S,
    signer: &G,
-
    resource: Oid,
+
    resource: Option<Oid>,
    parents: Vec<Oid>,
    identifier: &PublicKey,
    args: Update,
modified radicle-cob/src/test/storage.rs
@@ -67,7 +67,7 @@ impl change::Storage for Storage {

    fn store<Signer>(
        &self,
-
        authority: Self::Parent,
+
        authority: Option<Self::Parent>,
        parents: Vec<Self::Parent>,
        signer: &Signer,
        spec: change::Template<Self::ObjectId>,
modified radicle-cob/src/tests.rs
@@ -27,7 +27,7 @@ fn roundtrip() {
    let cob = create::<NonEmpty<Entry>, _, _>(
        &storage,
        &signer,
-
        proj.project.content_id,
+
        Some(proj.project.content_id),
        vec![],
        signer.public_key(),
        Create {
@@ -61,7 +61,7 @@ fn list_cobs() {
    let issue_1 = create::<NonEmpty<Entry>, _, _>(
        &storage,
        &signer,
-
        proj.project.content_id,
+
        Some(proj.project.content_id),
        vec![],
        signer.public_key(),
        Create {
@@ -77,7 +77,7 @@ fn list_cobs() {
    let issue_2 = create(
        &storage,
        &signer,
-
        proj.project.content_id,
+
        Some(proj.project.content_id),
        vec![],
        signer.public_key(),
        Create {
@@ -113,7 +113,7 @@ fn update_cob() {
    let cob = create::<NonEmpty<Entry>, _, _>(
        &storage,
        &signer,
-
        proj.project.content_id,
+
        Some(proj.project.content_id),
        vec![],
        signer.public_key(),
        Create {
@@ -133,7 +133,7 @@ fn update_cob() {
    let Updated { object, .. } = update(
        &storage,
        &signer,
-
        proj.project.content_id,
+
        Some(proj.project.content_id),
        vec![],
        signer.public_key(),
        Update {
@@ -174,7 +174,7 @@ fn traverse_cobs() {
    let cob = create::<NonEmpty<Entry>, _, _>(
        &storage,
        &terry_signer,
-
        terry_proj.project.content_id,
+
        Some(terry_proj.project.content_id),
        vec![],
        terry_signer.public_key(),
        Create {
@@ -198,7 +198,7 @@ fn traverse_cobs() {
    let Updated { object, .. } = update::<NonEmpty<Entry>, _, _>(
        &storage,
        &neil_signer,
-
        neil_proj.project.content_id,
+
        Some(neil_proj.project.content_id),
        vec![],
        neil_signer.public_key(),
        Update {
modified radicle-cob/src/trailers.rs
@@ -10,8 +10,6 @@ pub mod error {
    pub enum InvalidResourceTrailer {
        #[error("found wrong token for Rad-Resource tailer")]
        WrongToken,
-
        #[error("no Rad-Resource")]
-
        NoTrailer,
        #[error("no value for Rad-Resource")]
        NoValue,
        #[error("invalid git OID")]
modified radicle-httpd/src/api.rs
@@ -17,7 +17,7 @@ use tower_http::cors::{self, CorsLayer};

use radicle::cob::patch;
use radicle::cob::{issue, Uri};
-
use radicle::identity::Id;
+
use radicle::identity::{DocAt, Id};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::Profile;
@@ -54,7 +54,7 @@ impl Context {
        let storage = &self.profile.storage;
        let repo = storage.repository(id)?;
        let (_, head) = repo.head()?;
-
        let doc = repo.identity_doc()?.1.verified()?;
+
        let DocAt { doc, .. } = repo.identity_doc()?;
        let payload = doc.project()?;
        let delegates = doc.delegates;
        let issues = issue::Issues::open(&repo)?.counts()?;
modified radicle-httpd/src/api/error.rs
@@ -42,9 +42,9 @@ pub enum Error {
    #[error(transparent)]
    CobStore(#[from] radicle::cob::store::Error),

-
    /// Identity error.
+
    /// Repository error.
    #[error(transparent)]
-
    Identity(#[from] radicle::identity::IdentityError),
+
    Repository(#[from] radicle::storage::RepositoryError),

    /// Project doc error.
    #[error(transparent)]
@@ -115,10 +115,7 @@ impl IntoResponse for Error {
                tracing::error!("Error: {message}");

                if cfg!(debug_assertions) {
-
                    (
-
                        StatusCode::INTERNAL_SERVER_ERROR,
-
                        Some(format!("{other:?}")),
-
                    )
+
                    (StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
                } else {
                    (StatusCode::INTERNAL_SERVER_ERROR, None)
                }
modified radicle-httpd/src/api/v1/delegates.rs
@@ -5,7 +5,7 @@ use axum::{Json, Router};

use radicle::cob::issue::Issues;
use radicle::cob::patch::Patches;
-
use radicle::identity::Did;
+
use radicle::identity::{Did, DocAt};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};

@@ -42,12 +42,10 @@ async fn delegates_projects_handler(
        .filter_map(|id| {
            let Ok(repo) = storage.repository(id) else { return None };
            let Ok((_, head)) = repo.head() else { return None };
-
            let Ok((_, doc)) = repo.identity_doc() else { return None };
-
            let Ok(doc) = doc.verified() else { return None };
+
            let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
            let Ok(payload) = doc.project() else { return None };

-
            let delegates = doc.delegates;
-
            if !delegates.iter().any(|d| *d == delegate) {
+
            if !doc.delegates.iter().any(|d| *d == delegate) {
                return None;
            }

@@ -56,6 +54,7 @@ async fn delegates_projects_handler(
            let Ok(patches) = Patches::open(&repo) else { return None };
            let Ok(patches) = patches.counts() else { return None };

+
            let delegates = doc.delegates;
            let trackings = routing.count(&id).unwrap_or_default();

            Some(Info {
modified radicle-httpd/src/api/v1/projects.rs
@@ -14,7 +14,7 @@ use serde_json::{json, Value};
use tower_http::set_header::SetResponseHeaderLayer;

use radicle::cob::{issue, patch, Embed, Label, Uri};
-
use radicle::identity::{Did, Id};
+
use radicle::identity::{Did, DocAt, Id};
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::NodeId;
@@ -89,8 +89,7 @@ async fn project_root_handler(
        .filter_map(|id| {
            let Ok(repo) = storage.repository(id) else { return None };
            let Ok((_, head)) = repo.head() else { return None };
-
            let Ok((_, doc)) = repo.identity_doc() else { return None };
-
            let Ok(doc) = doc.verified() else { return None };
+
            let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
            let Ok(payload) = doc.project() else { return None };
            let Ok(issues) = issue::Issues::open(&repo) else { return None };
            let Ok(issues) = issues.counts() else { return None };
@@ -686,24 +685,16 @@ async fn issue_update_handler(
    api::auth::validate(&ctx, &token).await?;

    let storage = &ctx.profile.storage;
-
    let signer = ctx.profile.signer().unwrap();
+
    let signer = ctx.profile.signer()?;
    let repo = storage.repository(project)?;
    let mut issues = issue::Issues::open(&repo)?;
    let mut issue = issues.get_mut(&issue_id.into())?;

-
    match action {
-
        issue::Action::Assign { assignees } => {
-
            issue.assign(assignees, &signer)?;
-
        }
-
        issue::Action::Lifecycle { state } => {
-
            issue.lifecycle(state, &signer)?;
-
        }
-
        issue::Action::Label { labels } => {
-
            issue.label(labels, &signer)?;
-
        }
-
        issue::Action::Edit { title } => {
-
            issue.edit(title, &signer)?;
-
        }
+
    let id = match action {
+
        issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
+
        issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
+
        issue::Action::Label { labels } => issue.label(labels, &signer)?,
+
        issue::Action::Edit { title } => issue.edit(title, &signer)?,
        issue::Action::Comment {
            body,
            reply_to,
@@ -720,7 +711,7 @@ async fn issue_update_handler(
                })
                .collect();
            if let Some(to) = reply_to {
-
                issue.comment(body, to, embeds, &signer)?;
+
                issue.comment(body, to, embeds, &signer)?
            } else {
                return Err(Error::BadRequest("`replyTo` missing".to_owned()));
            }
@@ -729,9 +720,7 @@ async fn issue_update_handler(
            id,
            reaction,
            active,
-
        } => {
-
            issue.react(id, reaction, active, &signer)?;
-
        }
+
        } => issue.react(id, reaction, active, &signer)?,
        issue::Action::CommentEdit { id, body, embeds } => {
            let embeds: Vec<Embed> = embeds
                .into_iter()
@@ -743,14 +732,12 @@ async fn issue_update_handler(
                    })
                })
                .collect();
-
            issue.edit_comment(id, body, embeds, &signer)?;
-
        }
-
        issue::Action::CommentRedact { id } => {
-
            issue.redact_comment(id, &signer)?;
+
            issue.edit_comment(id, body, embeds, &signer)?
        }
+
        issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
    };

-
    Ok::<_, Error>(Json(json!({ "success": true })))
+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}

/// Get project issue.
@@ -830,41 +817,27 @@ async fn patch_update_handler(
    let repo = storage.repository(project)?;
    let mut patches = patch::Patches::open(&repo)?;
    let mut patch = patches.get_mut(&patch_id.into())?;
-
    match action {
-
        patch::Action::Edit { title, target } => {
-
            patch.edit(title, target, &signer)?;
-
        }
-
        patch::Action::Label { labels } => {
-
            patch.label(labels, &signer)?;
-
        }
-
        patch::Action::Lifecycle { state } => {
-
            patch.lifecycle(state, &signer)?;
-
        }
-
        patch::Action::Assign { assignees } => {
-
            patch.assign(assignees, &signer)?;
-
        }
+
    let id = match action {
+
        patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
+
        patch::Action::Label { labels } => patch.label(labels, &signer)?,
+
        patch::Action::Lifecycle { state } => patch.lifecycle(state, &signer)?,
+
        patch::Action::Assign { assignees } => patch.assign(assignees, &signer)?,
        patch::Action::Merge { revision, commit } => {
            // TODO: We should cleanup the stored copy at least.
-
            let _ = patch.merge(revision, commit, &signer)?;
+
            patch.merge(revision, commit, &signer)?.entry
        }
        patch::Action::Review {
            revision,
            summary,
            verdict,
            labels,
-
        } => {
-
            patch.review(revision, verdict, summary, labels, &signer)?;
-
        }
+
        } => *patch.review(revision, verdict, summary, labels, &signer)?,
        patch::Action::ReviewEdit {
            review,
            summary,
            verdict,
-
        } => {
-
            patch.edit_review(review, summary, verdict, &signer)?;
-
        }
-
        patch::Action::ReviewRedact { review } => {
-
            patch.redact_review(review, &signer)?;
-
        }
+
        } => patch.edit_review(review, summary, verdict, &signer)?,
+
        patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
        patch::Action::ReviewComment {
            review,
            body,
@@ -882,7 +855,7 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?;
+
            patch.review_comment(review, body, location, reply_to, embeds, &signer)?
        }
        patch::Action::ReviewCommentEdit {
            review,
@@ -900,42 +873,34 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.edit_review_comment(review, comment, body, embeds, &signer)?;
+
            patch.edit_review_comment(review, comment, body, embeds, &signer)?
        }
        patch::Action::ReviewCommentReact {
            review,
            comment,
            reaction,
            active,
-
        } => {
-
            patch.react_review_comment(review, comment, reaction, active, &signer)?;
-
        }
+
        } => patch.react_review_comment(review, comment, reaction, active, &signer)?,
        patch::Action::ReviewCommentRedact { review, comment } => {
-
            patch.redact_review_comment(review, comment, &signer)?;
+
            patch.redact_review_comment(review, comment, &signer)?
        }
        patch::Action::ReviewCommentResolve { review, comment } => {
-
            patch.resolve_review_comment(review, comment, &signer)?;
+
            patch.resolve_review_comment(review, comment, &signer)?
        }
        patch::Action::ReviewCommentUnresolve { review, comment } => {
-
            patch.unresolve_review_comment(review, comment, &signer)?;
+
            patch.unresolve_review_comment(review, comment, &signer)?
        }
        patch::Action::Revision {
            description,
            base,
            oid,
            ..
-
        } => {
-
            patch.update(description, base, oid, &signer)?;
-
        }
+
        } => patch.update(description, base, oid, &signer)?.into(),
        patch::Action::RevisionEdit {
            revision,
            description,
-
        } => {
-
            patch.edit_revision(revision, description, &signer)?;
-
        }
-
        patch::Action::RevisionRedact { revision } => {
-
            patch.redact(revision, &signer)?;
-
        }
+
        } => patch.edit_revision(revision, description, &signer)?,
+
        patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
        patch::Action::RevisionComment {
            revision,
            body,
@@ -953,7 +918,7 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.comment(revision, body, reply_to, location, embeds, &signer)?;
+
            patch.comment(revision, body, reply_to, location, embeds, &signer)?
        }
        patch::Action::RevisionCommentEdit {
            revision,
@@ -971,25 +936,23 @@ async fn patch_update_handler(
                    })
                })
                .collect();
-
            patch.comment_edit(revision, comment, body, embeds, &signer)?;
+
            patch.comment_edit(revision, comment, body, embeds, &signer)?
        }
        patch::Action::RevisionCommentReact {
            revision,
            comment,
            reaction,
            active,
-
        } => {
-
            patch.comment_react(revision, comment, reaction, active, &signer)?;
-
        }
+
        } => patch.comment_react(revision, comment, reaction, active, &signer)?,
        patch::Action::RevisionCommentRedact { revision, comment } => {
-
            patch.comment_redact(revision, comment, &signer)?;
+
            patch.comment_redact(revision, comment, &signer)?
        }
        _ => {
            todo!();
        }
    };

-
    Ok::<_, Error>(Json(json!({ "success": true })))
+
    Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}

/// Get project patches list.
@@ -2058,7 +2021,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_issues_create() {
-
        const CREATED_ISSUE_ID: &str = "e712eb0b5874d5256022fb620f26caf847d96723";
+
        const CREATED_ISSUE_ID: &str = "8b42657072f6192cba9e08561582576a975656cd";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -2166,7 +2129,7 @@ mod routes {
        .await;

        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(response.json().await, json!({ "success": true }));
+
        assert_eq!(response.success().await, true);

        let body = serde_json::to_vec(&json!({
          "type": "comment.react",
@@ -2199,11 +2162,11 @@ mod routes {
        )
        .await;

-
        assert_eq!(response.json().await, json!({ "success": true }));
+
        assert_eq!(response.success().await, true);

        let body = serde_json::to_vec(&json!({
          "type": "comment.redact",
-
          "id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
+
          "id": "918fff44966e4305523c077c80ac93b1196f1a7e",
        }))
        .unwrap();

@@ -2215,7 +2178,7 @@ mod routes {
        )
        .await;

-
        assert_eq!(response.json().await, json!({ "success": true }));
+
        assert_eq!(response.success().await, true);

        let response = get(
            &app,
@@ -2284,7 +2247,7 @@ mod routes {
        .await;

        assert_eq!(response.status(), StatusCode::OK);
-
        assert_eq!(response.json().await, json!({ "success": true }));
+
        assert_eq!(response.success().await, true);

        let response = get(
            &app,
@@ -2423,7 +2386,7 @@ mod routes {

    #[tokio::test]
    async fn test_projects_create_patches() {
-
        const CREATED_PATCH_ID: &str = "9cffd66099cceb0439a0f67c4aa99bde5e868eaa";
+
        const CREATED_PATCH_ID: &str = "beaed2e1d3b9b01ef10326a9a1c951799ba5fb25";

        let tmp = tempfile::tempdir().unwrap();
        let ctx = contributor(tmp.path());
@@ -2686,7 +2649,7 @@ mod routes {
                  "reviews": [],
                },
                {
-
                  "id": "b1f68feacb7040b089a77c1a0bff60a0411e6c1e",
+
                  "id": "341ba93c6db54e5891fbd3be4a4f64f4715681fa",
                  "author": {
                    "id": CONTRIBUTOR_DID,
                  },
@@ -2857,10 +2820,12 @@ mod routes {
        .await;

        assert_eq!(response.status(), StatusCode::OK);
+

+
        let comment_id = response.id().await.to_string();
        let comment_react_body = serde_json::to_vec(&json!({
          "type": "revision.comment.react",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": CONTRIBUTOR_COMMENT_1,
+
          "comment": comment_id,
          "reaction": "🚀",
          "active": true
        }))
@@ -2876,7 +2841,7 @@ mod routes {
        let comment_edit = serde_json::to_vec(&json!({
          "type": "revision.comment.edit",
          "revision": CONTRIBUTOR_PATCH_ID,
-
          "comment": CONTRIBUTOR_COMMENT_1,
+
          "comment": comment_id,
          "body": "EDIT: This is a root level comment",
          "embeds": [
            {
@@ -2899,7 +2864,7 @@ mod routes {
          "type": "revision.comment",
          "revision": CONTRIBUTOR_PATCH_ID,
          "body": "This is a root level comment",
-
          "replyTo": CONTRIBUTOR_COMMENT_1,
+
          "replyTo": comment_id,
          "embeds": [],
        }))
        .unwrap();
@@ -2912,6 +2877,7 @@ mod routes {
        .await;

        assert_eq!(response.status(), StatusCode::OK);
+
        let comment_id_2 = response.id().await.to_string();

        let response = get(
            &app,
@@ -2946,7 +2912,7 @@ mod routes {
                  ],
                  "discussions": [
                    {
-
                      "id": CONTRIBUTOR_COMMENT_1,
+
                      "id": comment_id,
                      "author": {
                        "id": CONTRIBUTOR_DID,
                      },
@@ -2963,7 +2929,7 @@ mod routes {
                      "resolved": false,
                    },
                    {
-
                      "id": CONTRIBUTOR_COMMENT_2,
+
                      "id": comment_id_2,
                      "author": {
                        "id": CONTRIBUTOR_DID,
                      },
@@ -2971,7 +2937,7 @@ mod routes {
                      "embeds": [],
                      "reactions": [],
                      "timestamp": TIMESTAMP,
-
                      "replyTo": CONTRIBUTOR_COMMENT_1,
+
                      "replyTo": comment_id,
                      "resolved": false,
                    },
                  ],
@@ -3006,9 +2972,10 @@ mod routes {

        assert_eq!(response.status(), StatusCode::OK);

+
        let review_id = response.id().await.to_string();
        let review_comment_body = serde_json::to_vec(&json!({
          "type": "review.comment",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
+
          "review": review_id,
          "body": "This is a comment on a review",
          "embeds": [],
          "location": {
@@ -3023,7 +2990,7 @@ mod routes {
          }
        }))
        .unwrap();
-
        patch(
+
        let response = patch(
            &app,
            format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
            Some(Body::from(review_comment_body)),
@@ -3031,10 +2998,11 @@ mod routes {
        )
        .await;

+
        let comment_id = response.id().await.to_string();
        let review_comment_edit_body = serde_json::to_vec(&json!({
          "type": "review.comment.edit",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
          "embeds": [],
          "body": "EDIT: This is a comment on a review",
        }))
@@ -3049,8 +3017,8 @@ mod routes {

        let review_react_body = serde_json::to_vec(&json!({
          "type": "review.comment.react",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
          "reaction": "🚀",
          "active": true
        }))
@@ -3065,8 +3033,8 @@ mod routes {

        let review_resolve_body = serde_json::to_vec(&json!({
          "type": "review.comment.resolve",
-
          "review": CONTRIBUTOR_PATCH_REVIEW,
-
          "comment": CONTRIBUTOR_COMMENT_3,
+
          "review": review_id,
+
          "comment": comment_id,
        }))
        .unwrap();
        patch(
@@ -3118,7 +3086,7 @@ mod routes {
                      "verdict": "accept",
                      "summary": "A small review",
                      "comments": [[
-
                        CONTRIBUTOR_COMMENT_3,
+
                        comment_id,
                        {
                          "author": CONTRIBUTOR_NID,
                          "location": {
modified radicle-httpd/src/lib.rs
@@ -143,6 +143,7 @@ pub mod logger {
    pub fn subscriber() -> impl tracing::Subscriber {
        tracing_subscriber::FmtSubscriber::builder()
            .with_target(false)
+
            .with_max_level(tracing::Level::DEBUG)
            .finish()
    }
}
modified radicle-httpd/src/test.rs
@@ -34,20 +34,16 @@ pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
-
pub const ISSUE_ID: &str = "0b0b8ca3b75e109971f87d92c1a6c930e87484c6";
-
pub const ISSUE_DISCUSSION_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
-
pub const ISSUE_COMMENT_ID: &str = "24ee306c508cd731a8427612dbdd826209096f99";
+
pub const ISSUE_ID: &str = "4f98396a1ac987af59ec069de9b80d9917b27050";
+
pub const ISSUE_DISCUSSION_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
+
pub const ISSUE_COMMENT_ID: &str = "59d35e164a21502bc91ad3391ce49baa32ea6a74";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
-
pub const CONTRIBUTOR_ISSUE_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
-
pub const CONTRIBUTOR_PATCH_ID: &str = "e651ae5869a2c1ac8ad4f6deae4cc835656ffa25";
-
pub const CONTRIBUTOR_PATCH_REVIEW: &str = "ee3eeba95f4ec418b3d0714e18e0d1ff605dc0e6";
-
pub const CONTRIBUTOR_COMMENT_1: &str = "d8ff07edbc8d2229e54e70f2f5bc31614287f0dc";
-
pub const CONTRIBUTOR_COMMENT_2: &str = "f3fca0add53f85bc51a85198efed3273fe13b88e";
-
pub const CONTRIBUTOR_COMMENT_3: &str = "06990ff59faa12463f693dae7a98eb33d75afd2e";
+
pub const CONTRIBUTOR_ISSUE_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
+
pub const CONTRIBUTOR_PATCH_ID: &str = "4ff2ec53a2d165da7f54705023e847d4f9230bc3";

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -96,6 +92,8 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    let routing_db = dir.join("radicle").join("node").join("routing.db");
    let addresses_db = dir.join("radicle").join("node").join("addresses.db");

+
    crate::logger::init().ok();
+

    TrackingStore::Config::open(tracking_db).unwrap();
    RoutingStore::Table::open(routing_db).unwrap();
    AddressStore::Book::open(addresses_db).unwrap();
@@ -191,7 +189,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    let storage = &profile.storage;
    let repo = storage.repository(id).unwrap();
    let mut issues = Issues::open(&repo).unwrap();
-
    let _ = issues
+
    let issue = issues
        .create(
            "Issue #1".to_string(),
            "Change 'hello world' to 'hello everyone'".to_string(),
@@ -201,12 +199,13 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
            signer,
        )
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor issue: {}", issue.id());

    // eq. rad patch open
    let mut patches = Patches::open(&repo).unwrap();
    let oid = radicle::git::Oid::from_str(HEAD).unwrap();
    let base = radicle::git::Oid::from_str(PARENT).unwrap();
-
    let _ = patches
+
    let patch = patches
        .create(
            "A new `hello world`",
            "change `hello world` in README to something else",
@@ -217,6 +216,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
            signer,
        )
        .unwrap();
+
    tracing::debug!(target: "test", "Contributor patch: {}", patch.id());

    let options = crate::Options {
        aliases: std::collections::HashMap::new(),
@@ -318,10 +318,24 @@ pub struct Response(axum::response::Response);

impl Response {
    pub async fn json(self) -> Value {
-
        let body = hyper::body::to_bytes(self.0.into_body()).await.unwrap();
+
        let body = self.body().await;
        serde_json::from_slice(&body).unwrap()
    }

+
    pub async fn id(self) -> radicle::git::Oid {
+
        let json = self.json().await;
+
        let string = json["id"].as_str().unwrap();
+

+
        radicle::git::Oid::from_str(string).unwrap()
+
    }
+

+
    pub async fn success(self) -> bool {
+
        let json = self.json().await;
+
        let success = json["success"].as_bool();
+

+
        success.unwrap_or(false)
+
    }
+

    pub fn status(&self) -> axum::http::StatusCode {
        self.0.status()
    }
modified radicle-node/src/service.rs
@@ -20,15 +20,14 @@ use localtime::{LocalDuration, LocalTime};
use log::*;
use nonempty::NonEmpty;

-
use radicle::identity;
use radicle::node::address;
use radicle::node::address::{AddressBook, KnownAddress};
use radicle::node::config::PeerConfig;
use radicle::node::ConnectOptions;
+
use radicle::storage::RepositoryError;

use crate::crypto;
use crate::crypto::{Signer, Verified};
-
use crate::identity::IdentityError;
use crate::identity::{Doc, Id};
use crate::node::routing;
use crate::node::routing::InsertResult;
@@ -118,7 +117,7 @@ pub enum Error {
    #[error(transparent)]
    Tracking(#[from] tracking::Error),
    #[error(transparent)]
-
    Identity(#[from] identity::IdentityError),
+
    Repository(#[from] radicle::storage::RepositoryError),
    #[error("namespaces error: {0}")]
    Namespaces(#[from] NamespacesError),
}
@@ -387,11 +386,11 @@ where
    }

    /// Lookup a project, both locally and in the routing table.
-
    pub fn lookup(&self, id: Id) -> Result<Lookup, LookupError> {
-
        let remote = self.routing.get(&id)?.iter().cloned().collect();
+
    pub fn lookup(&self, rid: Id) -> Result<Lookup, LookupError> {
+
        let remote = self.routing.get(&rid)?.iter().cloned().collect();

        Ok(Lookup {
-
            local: self.storage.get(&self.node_id(), id)?,
+
            local: self.storage.get(rid)?,
            remote,
        })
    }
@@ -1289,7 +1288,7 @@ where
        remotes: impl IntoIterator<Item = NodeId>,
    ) -> Result<(), Error> {
        let repo = self.storage.repository(rid)?;
-
        let (_, doc) = repo.identity_doc()?;
+
        let doc = repo.identity_doc()?;
        let peers = self.sessions.connected().map(|(_, p)| p);
        let timestamp = self.time();
        let mut refs = BoundedVec::<_, REF_REMOTE_LIMIT>::new();
@@ -1612,8 +1611,8 @@ pub trait ServiceState {
    fn nid(&self) -> &NodeId;
    /// Get the existing sessions.
    fn sessions(&self) -> &Sessions;
-
    /// Get a repository from storage, using the local node's key.
-
    fn get(&self, proj: Id) -> Result<Option<Doc<Verified>>, IdentityError>;
+
    /// Get a repository from storage.
+
    fn get(&self, rid: Id) -> Result<Option<Doc<Verified>>, RepositoryError>;
    /// Get the clock.
    fn clock(&self) -> &LocalTime;
    /// Get the clock mutably.
@@ -1636,8 +1635,8 @@ where
        &self.sessions
    }

-
    fn get(&self, proj: Id) -> Result<Option<Doc<Verified>>, IdentityError> {
-
        self.storage.get(&self.node_id(), proj)
+
    fn get(&self, rid: Id) -> Result<Option<Doc<Verified>>, RepositoryError> {
+
        self.storage.get(rid)
    }

    fn clock(&self) -> &LocalTime {
@@ -1716,11 +1715,9 @@ pub struct Lookup {
#[derive(thiserror::Error, Debug)]
pub enum LookupError {
    #[error(transparent)]
-
    Storage(#[from] storage::Error),
-
    #[error(transparent)]
    Routing(#[from] routing::Error),
    #[error(transparent)]
-
    Identity(#[from] IdentityError),
+
    Repository(#[from] RepositoryError),
}

/// Keeps track of the most recent announcements of a node.
modified radicle-node/src/service/tracking.rs
@@ -6,8 +6,7 @@ use log::error;
use thiserror::Error;

use radicle::crypto::PublicKey;
-
use radicle::identity::IdentityError;
-
use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage};
+
use radicle::storage::{Namespaces, ReadRepository as _, ReadStorage, RepositoryError};

use crate::prelude::Id;
use crate::service::NodeId;
@@ -37,7 +36,7 @@ pub enum NamespacesError {
    FailedDelegates {
        rid: Id,
        #[source]
-
        err: IdentityError,
+
        err: RepositoryError,
    },
    #[error("Could not find any trusted nodes for {rid}")]
    NoTrusted { rid: Id },
modified radicle-node/src/test/environment.rs
@@ -250,7 +250,7 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {

        loop {
            if let Ok(repo) = self.storage.repository(*rid) {
-
                if repo.identity_of(nid).is_ok() && repo.remote(nid).is_ok() {
+
                if repo.remote(nid).is_ok() {
                    break;
                }
            }
modified radicle-node/src/tests.rs
@@ -1289,6 +1289,19 @@ fn test_push_and_pull() {
    )
    .unwrap();

+
    let mut sim = Simulation::new(
+
        LocalTime::now(),
+
        alice.rng.clone(),
+
        simulator::Options::default(),
+
    )
+
    .initialize([&mut alice, &mut bob, &mut eve]);
+

+
    let bob_events = bob.events();
+

+
    // Neither Eve nor Bob have Alice's project for now.
+
    assert!(eve.get(proj_id).unwrap().is_none());
+
    assert!(bob.get(proj_id).unwrap().is_none());
+

    // Bob tracks Alice's project.
    let (sender, _) = chan::bounded(1);
    bob.command(service::Command::TrackRepo(
@@ -1296,7 +1309,6 @@ fn test_push_and_pull() {
        tracking::Scope::default(),
        sender,
    ));
-

    // Eve tracks Alice's project.
    let (sender, _) = chan::bounded(1);
    eve.command(service::Command::TrackRepo(
@@ -1305,22 +1317,6 @@ fn test_push_and_pull() {
        sender,
    ));

-
    let mut sim = Simulation::new(
-
        LocalTime::now(),
-
        alice.rng.clone(),
-
        simulator::Options::default(),
-
    )
-
    .initialize([&mut alice, &mut bob, &mut eve]);
-

-
    let bob_events = bob.events();
-

-
    // Here we expect Alice to connect to Eve.
-
    sim.run_while([&mut alice, &mut bob, &mut eve], |s| !s.is_settled());
-

-
    // Neither Eve nor Bob have Alice's project for now.
-
    assert!(eve.get(proj_id).unwrap().is_none());
-
    assert!(bob.get(proj_id).unwrap().is_none());
-

    let (send, _) = chan::bounded(1);
    // Alice announces her inventory.
    // We now expect Eve to fetch Alice's project from Alice.
@@ -1332,16 +1328,8 @@ fn test_push_and_pull() {

    // TODO: Refs should be compared between the two peers.

-
    assert!(eve
-
        .storage()
-
        .get(&alice.node_id(), proj_id)
-
        .unwrap()
-
        .is_some());
-
    assert!(bob
-
        .storage()
-
        .get(&alice.node_id(), proj_id)
-
        .unwrap()
-
        .is_some());
+
    assert!(eve.storage().get(proj_id).unwrap().is_some());
+
    assert!(bob.storage().get(proj_id).unwrap().is_some());

    bob_events
        .iter()
modified radicle-node/src/tests/e2e.rs
@@ -611,8 +611,8 @@ fn test_large_fetch() {
        )
        .unwrap();

-
    let (_, doc) = bob.storage.repository(rid).unwrap().identity_doc().unwrap();
-
    let proj = doc.verified().unwrap().project().unwrap();
+
    let doc = bob.storage.repository(rid).unwrap().identity_doc().unwrap();
+
    let proj = doc.project().unwrap();

    assert_eq!(proj.name(), "acme");
}
@@ -699,24 +699,24 @@ fn test_concurrent_fetches() {
    }

    for rid in &bob_repos {
-
        let (_, doc) = alice
+
        let doc = alice
            .storage
            .repository(*rid)
            .unwrap()
            .identity_doc()
            .unwrap();
-
        let proj = doc.verified().unwrap().project().unwrap();
+
        let proj = doc.project().unwrap();

        assert!(proj.name().starts_with("bob"));
    }
    for rid in &alice_repos {
-
        let (_, doc) = bob
+
        let doc = bob
            .storage
            .repository(*rid)
            .unwrap()
            .identity_doc()
            .unwrap();
-
        let proj = doc.verified().unwrap().project().unwrap();
+
        let proj = doc.project().unwrap();

        assert!(proj.name().starts_with("alice"));
    }
modified radicle-node/src/worker.rs
@@ -72,7 +72,9 @@ pub enum UploadError {
    #[error(transparent)]
    Storage(#[from] radicle::storage::Error),
    #[error(transparent)]
-
    Identity(#[from] radicle::identity::IdentityError),
+
    Identity(#[from] radicle::identity::DocError),
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}

impl UploadError {
@@ -323,7 +325,7 @@ impl Worker {
        log::debug!(target: "worker", "Received Git request pktline for {rid}..");

        let repo = self.storage.repository(rid)?;
-
        let (_, doc) = repo.identity_doc()?;
+
        let doc = repo.identity_doc()?;

        if !doc.is_visible_to(&remote) {
            return Err(UploadError::Unauthorized(remote, rid));
modified radicle-node/src/worker/fetch.rs
@@ -6,7 +6,7 @@ pub mod error;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::ops::Deref;

-
use radicle::crypto::{PublicKey, Unverified, Verified};
+
use radicle::crypto::{PublicKey, Verified};
use radicle::git::refspec;
use radicle::git::{url, Namespaced};
use radicle::prelude::{Doc, Id, NodeId};
@@ -188,8 +188,7 @@ impl<'a> StagingPhaseInitial<'a> {
                log::debug!(target: "worker", "Loading remotes for clone of {}", repo.id);
                let oid = ReadRepository::identity_head(&repo)?;
                log::trace!(target: "worker", "Loading 'rad/id' @ {oid}");
-
                let (doc, _) = Doc::<Unverified>::load_at(oid, &repo)?;
-
                let doc = doc.verified()?;
+
                let doc = Doc::<Verified>::load_at(oid, &repo)?.doc;
                let mut trusted = match self.namespaces.clone() {
                    Namespaces::All => HashSet::new(),
                    Namespaces::Trusted(trusted) => trusted,
@@ -520,10 +519,11 @@ impl<'a> StagingPhaseFinal<'a> {
                    }
                }

-
                let verification = match self.repo.identity_doc_of(&remote_id) {
+
                // Nb. We aren't verifying this specific remote's identity branch.
+
                let verification = match self.repo.identity_doc() {
                    Ok(doc) => match self.repo.validate_remote(&remote) {
                        Ok(unsigned) => VerifiedRemote::Success {
-
                            _doc: doc,
+
                            _doc: doc.into(),
                            remote,
                            unsigned,
                        },
modified radicle-node/src/worker/fetch/error.rs
@@ -17,9 +17,11 @@ pub enum Setup {
    #[error(transparent)]
    Git(#[from] git::raw::Error),
    #[error(transparent)]
-
    Identity(#[from] identity::IdentityError),
+
    Identity(#[from] identity::DocError),
    #[error(transparent)]
    Storage(#[from] storage::Error),
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}

#[derive(Debug, Error)]
@@ -27,21 +29,23 @@ pub enum Transfer {
    #[error(transparent)]
    Git(#[from] git::raw::Error),
    #[error(transparent)]
-
    Identity(#[from] identity::IdentityError),
+
    Identity(#[from] identity::DocError),
    #[error(transparent)]
    Storage(#[from] storage::Error),
    #[error("no delegates in transfer")]
    NoDelegates,
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}

#[derive(Debug, Error)]
pub enum Transition {
    #[error(transparent)]
-
    Doc(#[from] identity::doc::DocError),
-
    #[error(transparent)]
    Git(#[from] git::raw::Error),
    #[error(transparent)]
-
    Identity(#[from] identity::IdentityError),
+
    Identity(#[from] identity::DocError),
    #[error(transparent)]
    Refs(#[from] refs::Error),
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}
modified radicle-remote-helper/src/list.rs
@@ -13,13 +13,16 @@ pub enum Error {
    Storage(#[from] radicle::storage::Error),
    /// Identity error.
    #[error(transparent)]
-
    Identity(#[from] radicle::identity::IdentityError),
+
    Identity(#[from] radicle::identity::DocError),
    /// Git error.
    #[error(transparent)]
    Git(#[from] radicle::git::ext::Error),
    /// COB store error.
    #[error(transparent)]
    CobStore(#[from] cob::store::Error),
+
    /// General repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}

/// List refs for fetching (`git fetch` and `git ls-remote`).
modified radicle-remote-helper/src/push.rs
@@ -73,9 +73,6 @@ pub enum Error {
    /// Profile error.
    #[error(transparent)]
    Profile(#[from] radicle::profile::Error),
-
    /// Identity error.
-
    #[error(transparent)]
-
    Identity(#[from] radicle::identity::IdentityError),
    /// Parse error for object IDs.
    #[error(transparent)]
    ParseObjectId(#[from] ParseObjectId),
@@ -94,6 +91,9 @@ pub enum Error {
    /// COB store error.
    #[error(transparent)]
    Cob(#[from] radicle::cob::store::Error),
+
    /// General repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
}

enum Command {
modified radicle-term/src/lib.rs
@@ -13,6 +13,9 @@ pub mod table;
pub mod textarea;
pub mod vstack;

+
use std::fmt;
+
use std::io::IsTerminal;
+

pub use ansi::Color;
pub use ansi::{paint, Filled, Paint, Style};
pub use editor::Editor;
@@ -34,6 +37,10 @@ pub enum Interactive {
}

impl Interactive {
+
    pub fn new(term: impl IsTerminal) -> Self {
+
        Self::from(term.is_terminal())
+
    }
+

    pub fn yes(&self) -> bool {
        (*self).into()
    }
@@ -41,6 +48,14 @@ impl Interactive {
    pub fn no(&self) -> bool {
        !self.yes()
    }
+

+
    pub fn confirm(&self, prompt: impl fmt::Display) -> bool {
+
        if self.yes() {
+
            confirm(prompt)
+
        } else {
+
            true
+
        }
+
    }
}

impl From<Interactive> for bool {
modified radicle/src/cob/common.rs
@@ -1,4 +1,5 @@
-
use std::fmt::{self, Display};
+
use std::fmt;
+
use std::fmt::Display;
use std::str::FromStr;

use localtime::LocalTime;
@@ -30,6 +31,12 @@ impl Author {
    }
}

+
impl Display for Author {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        write!(f, "{}", self.id)
+
    }
+
}
+

impl From<PublicKey> for Author {
    fn from(value: PublicKey) -> Self {
        Self::new(value)
modified radicle/src/cob/identity.rs
@@ -1,105 +1,79 @@
use std::collections::BTreeMap;
-
use std::{ops::Deref, str::FromStr};
+
use std::{fmt, ops::Deref, str::FromStr};

use crypto::{PublicKey, Signature};
use once_cell::sync::Lazy;
use radicle_cob::{ObjectId, TypeName};
use radicle_crypto::{Signer, Verified};
+
use radicle_git_ext as git_ext;
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{
+
    cob,
    cob::{
-
        self, op,
-
        store::{self, Cob, CobAction, Transaction},
-
        Reaction, Timestamp,
+
        op, store,
+
        store::{Cob, CobAction, Transaction},
+
        ActorId, Timestamp,
    },
-
    identity::{doc::DocError, Did, Identity, IdentityError},
-
    prelude::{Doc, ReadRepository},
-
    storage::{RemoteId, WriteRepository},
+
    identity::{
+
        doc::{Doc, DocError, Id},
+
        Did,
+
    },
+
    storage::{ReadRepository, RepositoryError, WriteRepository},
};

-
use super::{
-
    thread::{self, Thread},
-
    Author, EntryId,
-
};
+
use super::{Author, EntryId};

/// Type name of an identity proposal.
pub static TYPENAME: Lazy<TypeName> =
-
    Lazy::new(|| FromStr::from_str("xyz.radicle.id.proposal").expect("type name is valid"));
+
    Lazy::new(|| FromStr::from_str("xyz.radicle.id").expect("type name is valid"));

+
/// Identity operation.
pub type Op = cob::Op<Action>;

-
pub type ProposalId = ObjectId;
-

+
/// Identifier for an identity revision.
pub type RevisionId = EntryId;

/// Proposal operation.
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Action {
-
    #[serde(rename = "accept")]
-
    Accept {
-
        revision: RevisionId,
-
        signature: Signature,
-
    },
-
    #[serde(rename = "close")]
-
    Close,
-
    #[serde(rename = "edit")]
-
    Edit {
-
        title: String,
-
        description: String,
-
    },
-
    Commit,
-
    #[serde(rename = "revision.redact")]
-
    RevisionRedact {
-
        revision: RevisionId,
-
    },
-
    #[serde(rename = "reject")]
-
    Reject {
-
        revision: RevisionId,
-
    },
    #[serde(rename = "revision")]
    Revision {
-
        // N.b. the `Oid` is a blob identifier and not a commit, so we
-
        // do not need to propagate it via HistoryAction.
-
        current: Oid,
-
        proposed: Doc<Verified>,
-
    },
-
    #[serde(rename_all = "camelCase")]
-
    #[serde(rename = "revision.comment")]
-
    RevisionComment {
-
        /// The revision to comment on.
-
        revision: RevisionId,
-
        /// Comment body.
-
        body: String,
-
        /// Comment this is a reply to.
-
        /// Should be [`None`] if it's the top-level comment.
-
        /// Should be the root [`EntryId`] if it's a top-level comment.
-
        reply_to: Option<EntryId>,
-
    },
-
    /// Edit a revision comment.
-
    #[serde(rename = "revision.comment.edit")]
-
    RevisionCommentEdit {
-
        revision: RevisionId,
-
        comment: EntryId,
-
        body: String,
+
        /// Short summary of changes.
+
        title: String,
+
        /// Longer comment on proposed changes.
+
        #[serde(default, skip_serializing_if = "String::is_empty")]
+
        description: String,
+
        /// Blob identifier of the document included in this action as an embed.
+
        /// Hence, we do not include it as a parent of this action in [`CobAction`].
+
        blob: Oid,
+
        /// Parent revision that this revision replaces.
+
        parent: Option<RevisionId>,
+
        /// Signature over the revision blob.
+
        signature: Signature,
    },
-
    /// Redact a revision comment.
-
    #[serde(rename = "revision.comment.redact")]
-
    RevisionCommentRedact {
+
    RevisionEdit {
+
        /// The revision to edit.
        revision: RevisionId,
-
        comment: EntryId,
+
        /// Short summary of changes.
+
        title: String,
+
        /// Longer comment on proposed changes.
+
        #[serde(default, skip_serializing_if = "String::is_empty")]
+
        description: String,
    },
-
    /// React to a revision comment.
-
    #[serde(rename = "revision.comment.react")]
-
    RevisionCommentReact {
+
    #[serde(rename = "revision.accept")]
+
    RevisionAccept {
        revision: RevisionId,
-
        comment: EntryId,
-
        reaction: Reaction,
-
        active: bool,
+
        /// Signature over the blob.
+
        signature: Signature,
    },
+
    #[serde(rename = "revision.reject")]
+
    RevisionReject { revision: RevisionId },
+
    #[serde(rename = "revision.redact")]
+
    RevisionRedact { revision: RevisionId },
}

impl CobAction for Action {}
@@ -116,41 +90,31 @@ pub enum ApplyError {
    /// that hasn't happened yet.
    #[error("causal dependency {0:?} missing")]
    Missing(EntryId),
-
    #[error("the proposal is committed")]
-
    Committed,
-
    #[error(transparent)]
-
    Commit(#[from] CommitError),
-
    /// Error applying an op to the proposal thread.
-
    #[error("thread apply failed: {0}")]
-
    Thread(#[from] thread::Error),
-
    /// Error validating the state.
-
    #[error("validation failed: {0}")]
-
    Validate(&'static str),
-
}
-

-
/// Error committing the proposal.
-
#[derive(Error, Debug)]
-
pub enum CommitError {
-
    #[error(transparent)]
-
    Identity(#[from] IdentityError),
-
    #[error("the proposal {0} is closed")]
-
    Closed(EntryId),
-
    #[error("the revision {0} is missing")]
-
    Missing(EntryId),
-
    #[error("the identity hashes do not match for revision {revision} ({current} =/= {expected})")]
-
    Mismatch {
-
        current: Oid,
-
        expected: Oid,
-
        revision: EntryId,
-
    },
-
    #[error("the revision {0} is already committed")]
-
    Committed(EntryId),
-
    #[error("the revision {0} is redacted")]
-
    Redacted(EntryId),
-
    #[error(transparent)]
+
    /// General error initializing an identity.
+
    #[error("initialization failed: {0}")]
+
    Init(&'static str),
+
    /// Invalid signature over document blob.
+
    #[error("invalid signature from {0} for blob {1}")]
+
    InvalidSignature(PublicKey, Oid),
+
    /// Unauthorized action.
+
    #[error("not authorized to perform this action")]
+
    NotAuthorized,
+
    #[error("parent id is missing from revision")]
+
    MissingParent,
+
    #[error("verdict for this revision has already been applied")]
+
    DuplicateVerdict,
+
    #[error("revision is in an unexpected state")]
+
    UnexpectedState,
+
    #[error("revision has been redacted")]
+
    Redacted,
+
    #[error("document does not contain any changes to current identity")]
+
    DocUnchanged,
+
    #[error("git: {0}")]
+
    Git(#[from] git2::Error),
+
    #[error("git: {0}")]
+
    GitExt(#[from] git_ext::Error),
+
    #[error("identity document error: {0}")]
    Doc(#[from] DocError),
-
    #[error("signatures did not reach quorum threshold: {0}")]
-
    Quorum(usize),
}

/// Error updating or creating proposals.
@@ -162,162 +126,164 @@ pub enum Error {
    Store(#[from] store::Error),
    #[error("op decoding failed: {0}")]
    Op(#[from] op::OpEncodingError),
+
    #[error(transparent)]
+
    Doc(#[from] DocError),
}

-
/// Propose a new [`Doc`] for an [`Identity`]. The proposal can be
-
/// reviewed by gathering [`Signature`]s for accepting the changes, or
-
/// rejecting them.
-
///
-
/// Once a proposal has reached the quourum threshold for the previous
-
/// [`Identity`] then it may be committed to the person's local
-
/// storage using [`Proposal::commit`].
-
#[derive(Debug, Clone, PartialEq, Eq, Default)]
-
pub struct Proposal {
-
    /// Title of the proposal.
-
    title: String,
-
    /// Description of the proposal.
-
    description: String,
-
    /// Current state of the proposal.
-
    state: State,
-
    /// List of revisions for this proposal.
+
/// An evolving identity document.
+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct Identity {
+
    /// The canonical identifier for this identity.
+
    /// This is the object id of the initial document blob.
+
    pub id: Id,
+
    /// The current revision of the document.
+
    /// Equal to the head of the identity branch.
+
    pub current: RevisionId,
+
    /// The initial revision of the document.
+
    pub root: RevisionId,
+
    /// The latest revision that each delegate has accepted.
+
    /// Delegates can only accept one revision at a time.
+
    pub heads: BTreeMap<Did, RevisionId>,
+

+
    /// Revisions.
    revisions: BTreeMap<RevisionId, Option<Revision>>,
    /// Timeline of events.
    timeline: Vec<EntryId>,
}

-
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase")]
-
pub enum State {
-
    #[default]
-
    Open,
-
    Closed,
-
    Committed,
+
impl std::ops::Deref for Identity {
+
    type Target = Revision;
+

+
    fn deref(&self) -> &Self::Target {
+
        self.current()
+
    }
}

-
impl Proposal {
-
    /// Commit the [`Doc`], found at the given `revision`, to the
-
    /// provided `remote`.
-
    ///
-
    /// # Errors
-
    ///
-
    /// This operation will fail if:
-
    ///   * The `revision` is missing
-
    ///   * The `revision` is redacted
-
    ///   * The number of signatures for this revision does not reach
-
    ///     the quorum for the previous [`Doc`].
-
    pub fn commit<R, G>(
-
        &self,
-
        rid: &RevisionId,
-
        remote: &RemoteId,
-
        repo: &R,
-
        signer: &G,
-
    ) -> Result<Identity<Oid>, CommitError>
-
    where
-
        R: WriteRepository,
-
        G: Signer,
-
    {
-
        match self.state() {
-
            State::Closed => return Err(CommitError::Closed(*rid)),
-
            State::Committed => return Err(CommitError::Committed(*rid)),
-
            State::Open => {}
-
        }
-
        let revision = self
-
            .revision(rid)
-
            .ok_or_else(|| CommitError::Missing(*rid))?
-
            .as_ref()
-
            .ok_or_else(|| CommitError::Redacted(*rid))?;
-
        let doc = &revision.proposed;
-
        let previous = Identity::load(signer.public_key(), repo)?;
-

-
        if previous.current != revision.current {
-
            return Err(CommitError::Mismatch {
-
                current: revision.current,
-
                expected: previous.current,
-
                revision: *rid,
-
            });
-
        }
+
impl Identity {
+
    pub fn new(revision: Revision) -> Self {
+
        let root = revision.id;

-
        if !revision.is_quorum_reached(&previous) {
-
            return Err(CommitError::Quorum(doc.threshold));
+
        Self {
+
            id: revision.blob.into(),
+
            root,
+
            current: root,
+
            heads: revision
+
                .delegates
+
                .iter()
+
                .copied()
+
                .map(|did| (did, root))
+
                .collect(),
+
            revisions: BTreeMap::from_iter([(root, Some(revision))]),
+
            timeline: vec![root],
        }
+
    }

-
        let signatures = revision.signatures();
-
        let msg = format!(
-
            "{}\n\n{}",
-
            self.title(),
-
            self.description().unwrap_or_default()
-
        );
-
        let current = doc.update(remote, &msg, &signatures.collect::<Vec<_>>(), repo.raw())?;
-
        let head = repo.set_identity_head()?;
-

-
        assert_eq!(head, current);
-

-
        Ok(Identity {
-
            head,
-
            root: previous.root,
-
            current,
-
            revision: previous.revision + 1,
-
            doc: doc.clone(),
-
            signatures: revision
-
                .signatures()
-
                .map(|(key, sig)| (*key, sig))
-
                .collect(),
+
    pub fn initialize<'a, R: WriteRepository + cob::Store, G: Signer>(
+
        doc: &Doc<Verified>,
+
        store: &'a R,
+
        signer: &G,
+
    ) -> Result<IdentityMut<'a, R>, cob::store::Error> {
+
        let mut store = cob::store::Store::open(store)?;
+
        let (id, identity) =
+
            Transaction::<Identity, _>::initial("Initialize identity", &mut store, signer, |tx| {
+
                tx.revision("Initial revision", "", doc, None, signer)
+
            })?;
+

+
        Ok(IdentityMut {
+
            id,
+
            identity,
+
            store,
        })
    }

-
    pub fn is_committed(&self) -> bool {
-
        match self.state() {
-
            State::Open => false,
-
            State::Closed => false,
-
            State::Committed => true,
-
        }
+
    pub fn get<R: ReadRepository + cob::Store>(
+
        object: &ObjectId,
+
        repo: &R,
+
    ) -> Result<Identity, store::Error> {
+
        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))
+
    }
+

+
    /// Get a proposal mutably.
+
    pub fn get_mut<'a, R: WriteRepository + cob::Store>(
+
        id: &ObjectId,
+
        repo: &'a R,
+
    ) -> Result<IdentityMut<'a, R>, store::Error> {
+
        let obj = Self::get(id, repo)?;
+
        let store = cob::store::Store::open(repo)?;
+

+
        Ok(IdentityMut {
+
            id: *id,
+
            identity: obj,
+
            store,
+
        })
    }

-
    /// The most recent title for the proposal.
-
    pub fn title(&self) -> &str {
-
        &self.title
+
    pub fn load<R: ReadRepository + cob::Store>(repo: &R) -> Result<Identity, RepositoryError> {
+
        let oid = repo.identity_root()?;
+
        let oid = ObjectId::from(oid);
+

+
        Self::get(&oid, repo).map_err(RepositoryError::from)
    }

-
    /// The most recent description for the proposal, if present.
-
    pub fn description(&self) -> Option<&str> {
-
        Some(self.description.as_str())
+
    pub fn load_mut<R: WriteRepository + cob::Store>(
+
        repo: &R,
+
    ) -> Result<IdentityMut<R>, RepositoryError> {
+
        let oid = repo.identity_root()?;
+
        let oid = ObjectId::from(oid);
+

+
        Self::get_mut(&oid, repo).map_err(RepositoryError::from)
    }
+
}

-
    pub fn state(&self) -> &State {
-
        &self.state
+
impl Identity {
+
    /// The repository identifier.
+
    pub fn id(&self) -> Id {
+
        self.id
    }

-
    /// A specific [`Revision`], that may be redacted.
-
    pub fn revision(&self, revision: &RevisionId) -> Option<&Option<Revision>> {
-
        self.revisions.get(revision)
+
    /// The current document.
+
    pub fn doc(&self) -> &Doc<Verified> {
+
        &self.current().doc
    }

-
    /// All the [`Revision`]s that have not been redacted.
-
    pub fn revisions(&self) -> impl DoubleEndedIterator<Item = (&RevisionId, &Revision)> {
-
        self.timeline.iter().filter_map(|id| {
-
            self.revisions
-
                .get(id)
-
                .and_then(|o| o.as_ref())
-
                .map(|rev| (id, rev))
-
        })
+
    /// The current revision.
+
    pub fn current(&self) -> &Revision {
+
        self.revision(&self.current)
+
            .expect("Identity::current: the current revision must always exist")
    }

-
    pub fn latest_by(&self, who: &Did) -> Option<(&RevisionId, &Revision)> {
-
        self.revisions().rev().find_map(|(rid, r)| {
-
            if r.author.id() == who {
-
                Some((rid, r))
-
            } else {
-
                None
-
            }
-
        })
+
    /// The initial revision of this identity.
+
    pub fn root(&self) -> &Revision {
+
        self.revision(&self.root)
+
            .expect("Identity::root: the root revision must always exist")
    }

-
    pub fn latest(&self) -> Option<(&RevisionId, &Revision)> {
-
        self.revisions().next_back()
+
    /// The head of the identity branch. This points to a commit that
+
    /// contains the current document blob.
+
    pub fn head(&self) -> Oid {
+
        self.current
+
    }
+

+
    /// A specific [`Revision`], that may be redacted.
+
    pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> {
+
        self.revisions.get(revision).and_then(|r| r.as_ref())
+
    }
+

+
    /// All the [`Revision`]s that have not been redacted.
+
    pub fn revisions(&self) -> impl DoubleEndedIterator<Item = &Revision> {
+
        self.timeline
+
            .iter()
+
            .filter_map(|id| self.revisions.get(id).and_then(|o| o.as_ref()))
+
    }
+

+
    pub fn latest_by(&self, who: &Did) -> Option<&Revision> {
+
        self.revisions().rev().find(|r| r.author.id() == who)
    }
}

-
impl store::Cob for Proposal {
+
impl store::Cob for Identity {
    type Action = Action;
    type Error = ApplyError;

@@ -326,126 +292,289 @@ impl store::Cob for Proposal {
    }

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
-
        let mut identity = Self::default();
-
        identity.op(op, repo)?;
-
        Ok(identity)
+
        let mut actions = op.actions.into_iter();
+
        let Some(
+
            Action::Revision { title, description, blob, signature, parent }
+
        ) = actions.next() else {
+
            return Err(ApplyError::Init("the first action must be of type `revision`"));
+
        };
+
        if parent.is_some() {
+
            return Err(ApplyError::Init(
+
                "the initial revision must not have a parent",
+
            ));
+
        }
+
        if actions.next().is_some() {
+
            return Err(ApplyError::Init(
+
                "the first operation must contain only one action",
+
            ));
+
        }
+
        let root = Doc::<Verified>::load_at(op.id, repo)?;
+
        if root.blob != blob {
+
            return Err(ApplyError::Init("invalid object id specified in revision"));
+
        }
+
        if root.blob != *repo.id() {
+
            return Err(ApplyError::Init(
+
                "repository root does not match identifier",
+
            ));
+
        }
+
        assert_eq!(root.commit, op.id);
+

+
        let founder = root.delegates.first();
+
        if founder.as_key() != &op.author {
+
            return Err(ApplyError::Init("delegate does not match committer"));
+
        }
+
        // Verify signature against root document. Since there is no previous document,
+
        // we verify it against itself.
+
        if root
+
            .verify_signature(founder, &signature, root.blob)
+
            .is_err()
+
        {
+
            return Err(ApplyError::InvalidSignature(**founder, root.blob));
+
        }
+
        let revision = Revision::new(
+
            root.commit,
+
            title,
+
            description,
+
            op.author.into(),
+
            root.blob,
+
            root.doc,
+
            State::Accepted,
+
            signature,
+
            parent,
+
            op.timestamp,
+
        );
+
        Ok(Identity::new(revision))
    }

-
    fn op<R: ReadRepository>(&mut self, op: Op, _repo: &R) -> Result<(), ApplyError> {
+
    fn op<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), ApplyError> {
        let id = op.id;
-
        let author = Author::new(op.author);
-
        let timestamp = op.timestamp;

-
        debug_assert!(!self.timeline.contains(&op.id));
+
        for action in op.actions {
+
            match self.action(action, id, op.author, op.timestamp, repo) {
+
                Ok(()) => {}
+
                // This particular error is returned when there is a mismatch between the expected
+
                // and the actual state of a revision, which can happen concurrently. Therefore
+
                // it is not fatal and we simply ignore it.
+
                Err(ApplyError::UnexpectedState) => {}
+
                // It's not a user error if the revision happens to be redacted by
+
                // the time this action is processed.
+
                Err(ApplyError::Redacted) => {}
+
                Err(other) => return Err(other),
+
            }
+
            debug_assert!(!self.timeline.contains(&id));
+
            self.timeline.push(id);
+
        }
+
        Ok(())
+
    }
+
}

-
        self.timeline.push(id);
+
impl Identity {
+
    /// Apply a single action to the identity document.
+
    ///
+
    /// This function ensures a few things:
+
    /// * Only delegates can interact with the state.
+
    /// * There is only ever one accepted revision; this is the "current" revision.
+
    /// * There can be zero or more active revisions, up to the number of delegates.
+
    /// * An active revision is one that can be "voted" on.
+
    /// * An active revision always has the current revision as parent.
+
    /// * Only the active revision can be accepted, rejected or edited.
+
    fn action<R: ReadRepository>(
+
        &mut self,
+
        action: Action,
+
        entry: EntryId,
+
        author: ActorId,
+
        timestamp: Timestamp,
+
        repo: &R,
+
    ) -> Result<(), ApplyError> {
+
        let current = self.current().clone();

-
        for action in op.actions {
-
            match action {
-
                Action::Accept {
-
                    revision,
-
                    signature,
-
                } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        revision.accept(op.author, signature);
-
                    }
+
        if !current.is_delegate(&author) {
+
            return Err(ApplyError::UnexpectedState);
+
        }
+
        match action {
+
            Action::RevisionAccept {
+
                revision,
+
                signature,
+
            } => {
+
                let id = revision;
+
                let Some(revision) = lookup::revision_mut(&mut self.revisions, &id)? else {
+
                    return Err(ApplyError::Redacted);
+
                };
+
                if !revision.is_active() {
+
                    // You can't vote on an inactive revision.
+
                    return Err(ApplyError::UnexpectedState);
                }
-
                Action::Close => self.state = State::Closed,
-
                Action::Edit { title, description } => {
-
                    self.title = title;
-
                    self.description = description;
+
                assert_eq!(revision.parent, Some(current.id));
+

+
                self.heads.insert(author.into(), id);
+
                revision.accept(author, signature, &current)?;
+

+
                self.adopt(id);
+
            }
+
            Action::RevisionReject { revision } => {
+
                let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
+
                    return Err(ApplyError::Redacted);
+
                };
+
                if !revision.is_active() {
+
                    // You can't vote on an inactive revision.
+
                    return Err(ApplyError::UnexpectedState);
                }
-
                Action::Commit => self.state = State::Committed,
-
                Action::RevisionRedact { revision } => {
-
                    if let Some(revision) = self.revisions.get_mut(&revision) {
-
                        *revision = None;
-
                    } else {
-
                        return Err(ApplyError::Missing(revision));
-
                    }
+
                assert_eq!(revision.parent, Some(current.id));
+

+
                revision.reject(author)?;
+
            }
+
            Action::RevisionEdit {
+
                title,
+
                description,
+
                revision,
+
            } => {
+
                if revision == self.current {
+
                    return Err(ApplyError::NotAuthorized);
                }
-
                Action::Reject { revision } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        revision.reject(op.author);
-
                    }
+
                let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
+
                    return Err(ApplyError::Redacted);
+
                };
+
                if !revision.is_active() {
+
                    // You can't edit an inactive revision.
+
                    return Err(ApplyError::UnexpectedState);
                }
-
                Action::Revision { current, proposed } => {
-
                    debug_assert!(!self.revisions.contains_key(&id));
-

-
                    self.revisions.insert(
-
                        id,
-
                        Some(Revision::new(author.clone(), current, proposed, timestamp)),
-
                    );
+
                if revision.author.public_key() != &author {
+
                    // Can't edit someone else's revision.
+
                    // Since the author never changes, we can safely mark this as invalid.
+
                    return Err(ApplyError::NotAuthorized);
                }
+
                assert_eq!(revision.parent, Some(current.id));

-
                Action::RevisionComment {
-
                    revision,
-
                    body,
-
                    reply_to,
-
                } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::comment(
-
                            &mut revision.discussion,
-
                            op.id,
-
                            op.author,
-
                            op.timestamp,
-
                            body,
-
                            reply_to,
-
                            None,
-
                            vec![],
-
                        )?;
-
                    }
+
                revision.title = title;
+
                revision.description = description;
+
            }
+
            Action::RevisionRedact { revision } => {
+
                if revision == self.current {
+
                    // Can't redact the current revision.
+
                    return Err(ApplyError::UnexpectedState);
                }
-
                Action::RevisionCommentEdit {
-
                    revision,
-
                    comment,
-
                    body,
-
                } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::edit(
-
                            &mut revision.discussion,
-
                            op.id,
-
                            comment,
-
                            op.timestamp,
-
                            body,
-
                            vec![],
-
                        )?;
+
                if let Some(revision) = self.revisions.get_mut(&revision) {
+
                    if let Some(r) = revision {
+
                        if r.is_accepted() {
+
                            // You can't redact an accepted revision.
+
                            return Err(ApplyError::UnexpectedState);
+
                        }
+
                        if r.author.public_key() != &author {
+
                            // Can't redact someone else's revision.
+
                            // Since the author never changes, we can safely mark this as invalid.
+
                            return Err(ApplyError::NotAuthorized);
+
                        }
+
                        *revision = None;
                    }
+
                } else {
+
                    return Err(ApplyError::Missing(revision));
                }
-
                Action::RevisionCommentRedact { revision, comment } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::redact(&mut revision.discussion, op.id, comment)?;
+
            }
+
            Action::Revision {
+
                title,
+
                description,
+
                blob,
+
                signature,
+
                parent,
+
            } => {
+
                debug_assert!(!self.revisions.contains_key(&entry));
+

+
                let doc = repo.blob(blob)?;
+
                let doc = Doc::from_blob(&doc)?;
+
                // All revisions but the first one must have a parent.
+
                let Some(parent) = parent else {
+
                    return Err(ApplyError::MissingParent);
+
                };
+
                let Some(parent) = lookup::revision(&self.revisions, &parent)? else {
+
                    return Err(ApplyError::Redacted);
+
                };
+
                // If the parent of this revision is no longer the current document, this
+
                // revision can be marked as outdated.
+
                let state = if parent.id == current.id {
+
                    // If the revision is not outdated, we expect it to make a change to the
+
                    // current version.
+
                    if doc == parent.doc {
+
                        return Err(ApplyError::DocUnchanged);
                    }
+
                    State::Active
+
                } else {
+
                    State::Stale
+
                };
+

+
                // Verify signature over new blob, using trusted delegates.
+
                if parent.verify_signature(&author, &signature, blob).is_err() {
+
                    return Err(ApplyError::InvalidSignature(author, blob));
                }
-
                Action::RevisionCommentReact {
-
                    revision,
-
                    comment,
-
                    reaction,
-
                    active,
-
                } => {
-
                    if let Some(revision) = lookup::revision(self, &revision)? {
-
                        thread::react(
-
                            &mut revision.discussion,
-
                            op.id,
-
                            op.author,
-
                            comment,
-
                            reaction,
-
                            active,
-
                        )?;
-
                    }
+
                let revision = Revision::new(
+
                    entry,
+
                    title,
+
                    description,
+
                    author.into(),
+
                    blob,
+
                    doc,
+
                    state,
+
                    signature,
+
                    Some(parent.id),
+
                    timestamp,
+
                );
+
                let id = revision.id;
+

+
                self.heads.insert(author.into(), id);
+
                self.revisions.insert(id, Some(revision));
+

+
                if state == State::Active {
+
                    self.adopt(id);
                }
            }
        }
-

        Ok(())
    }
+

+
    /// Try to adopt a revision as the current one.
+
    fn adopt(&mut self, id: RevisionId) {
+
        if self.current == id {
+
            return;
+
        }
+
        let votes = self
+
            .heads
+
            .values()
+
            .filter(|revision| **revision == id)
+
            .count();
+
        if self.is_majority(votes) {
+
            self.current = id;
+
            self.current_mut().state = State::Accepted;
+

+
            // Void all other active revisions.
+
            for r in self
+
                .revisions
+
                .iter_mut()
+
                .filter_map(|(_, r)| r.as_mut())
+
                .filter(|r| r.state == State::Active)
+
            {
+
                r.state = State::Stale;
+
            }
+
        }
+
    }
+

+
    /// A specific [`Revision`], mutably.
+
    fn revision_mut(&mut self, revision: &RevisionId) -> Option<&mut Revision> {
+
        self.revisions.get_mut(revision).and_then(|r| r.as_mut())
+
    }
+

+
    /// The current revision, mutably.
+
    fn current_mut(&mut self) -> &mut Revision {
+
        let current = self.current;
+
        self.revision_mut(&current)
+
            .expect("Identity::current_mut: the current revision must always exist")
+
    }
}

-
impl<R: ReadRepository> cob::Evaluate<R> for Proposal {
+
impl<R: ReadRepository> cob::Evaluate<R> for Identity {
    type Error = Error;

    fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
        let op = Op::try_from(entry)?;
-
        let object = Proposal::from_root(op, repo)?;
+
        let object = Identity::from_root(op, repo)?;

        Ok(object)
    }
@@ -457,23 +586,6 @@ impl<R: ReadRepository> cob::Evaluate<R> for Proposal {
    }
}

-
mod lookup {
-
    use super::*;
-

-
    pub fn revision<'a>(
-
        proposal: &'a mut Proposal,
-
        revision: &RevisionId,
-
    ) -> Result<Option<&'a mut Revision>, ApplyError> {
-
        match proposal.revisions.get_mut(revision) {
-
            Some(Some(revision)) => Ok(Some(revision)),
-
            // Redacted.
-
            Some(None) => Ok(None),
-
            // Missing. Causal error.
-
            None => Err(ApplyError::Missing(*revision)),
-
        }
-
    }
-
}
-

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
    /// An accepting verdict must supply the [`Signature`] over the
@@ -483,40 +595,75 @@ pub enum Verdict {
    Reject,
}

+
/// State of a revision.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub enum State {
+
    /// The revision is actively being voted on. From here, it can go into any of the
+
    /// other states.
+
    Active,
+
    /// The revision has been accepted by a majority of delegates. Once accepted,
+
    /// a revision doesn't change state.
+
    Accepted,
+
    /// The revision was rejected by a majority of delegates. Once rejected,
+
    /// a revision doesn't change state.
+
    Rejected,
+
    /// The revision was active, but has been replaced by another revision,
+
    /// and is now outdated. Once stale, a revision doesn't change state.
+
    Stale,
+
}
+

+
impl std::fmt::Display for State {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Self::Active => write!(f, "active"),
+
            Self::Accepted => write!(f, "accepted"),
+
            Self::Rejected => write!(f, "rejected"),
+
            Self::Stale => write!(f, "stale"),
+
        }
+
    }
+
}
+

+
/// A new [`Doc`] for an [`Identity`]. The revision can be
+
/// reviewed by gathering [`Signature`]s for accepting the changes, or
+
/// rejecting them.
+
///
+
/// Once a revision has reached the quorum threshold of the previous
+
/// [`Identity`] it is then adopted as the current identity.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Revision {
+
    /// The id of this revision. Points to a commit.
+
    pub id: RevisionId,
+
    /// Identity document blob at this revision.
+
    pub blob: Oid,
+
    /// Title of the proposal.
+
    pub title: String,
+
    /// State of the revision.
+
    pub state: State,
+
    /// Description of the proposal.
+
    pub description: String,
    /// Author of this proposed revision.
    pub author: Author,
-
    /// [`Identity::current`]'s current [`Oid`] that this revision was
-
    /// based on.
-
    pub current: Oid,
    /// New [`Doc`] that will replace `previous`' document.
-
    pub proposed: Doc<Verified>,
-
    /// Discussion thread for this revision.
-
    pub discussion: Thread,
-
    /// [`Verdict`]s given by the delegates.
-
    pub verdicts: BTreeMap<PublicKey, Option<Verdict>>,
+
    pub doc: Doc<Verified>,
    /// Physical timestamp of this proposal revision.
    pub timestamp: Timestamp,
+
    /// Parent revision.
+
    pub parent: Option<RevisionId>,
+

+
    /// Signatures and rejections given by the delegates.
+
    verdicts: BTreeMap<PublicKey, Verdict>,
}

-
impl Revision {
-
    pub fn new(
-
        author: Author,
-
        current: Oid,
-
        proposed: Doc<Verified>,
-
        timestamp: Timestamp,
-
    ) -> Self {
-
        Self {
-
            author,
-
            current,
-
            proposed,
-
            discussion: Thread::default(),
-
            verdicts: BTreeMap::default(),
-
            timestamp,
-
        }
+
impl std::ops::Deref for Revision {
+
    type Target = Doc<Verified>;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.doc
    }
+
}

+
impl Revision {
    pub fn signatures(&self) -> impl Iterator<Item = (&PublicKey, Signature)> {
        self.verdicts().filter_map(|(key, verdict)| match verdict {
            Verdict::Accept(sig) => Some((key, *sig)),
@@ -524,73 +671,125 @@ impl Revision {
        })
    }

+
    pub fn is_accepted(&self) -> bool {
+
        matches!(self.state, State::Accepted)
+
    }
+

+
    pub fn is_active(&self) -> bool {
+
        matches!(self.state, State::Active)
+
    }
+

    pub fn verdicts(&self) -> impl Iterator<Item = (&PublicKey, &Verdict)> {
-
        self.verdicts
-
            .iter()
-
            .filter_map(|(key, verdict)| verdict.as_ref().map(|verdict| (key, verdict)))
+
        self.verdicts.iter()
    }

-
    pub fn accepted(&self) -> Vec<Did> {
-
        self.verdicts()
-
            .filter_map(|(key, v)| match v {
-
                Verdict::Accept(_) => Some(key.into()),
-
                Verdict::Reject => None,
-
            })
-
            .collect()
+
    pub fn accepted(&self) -> impl Iterator<Item = Did> + '_ {
+
        self.signatures().map(|(key, _)| key.into())
    }

-
    pub fn rejected(&self) -> Vec<Did> {
-
        self.verdicts()
-
            .filter_map(|(key, v)| match v {
-
                Verdict::Accept(_) => None,
-
                Verdict::Reject => Some(key.into()),
-
            })
-
            .collect()
+
    pub fn rejected(&self) -> impl Iterator<Item = Did> + '_ {
+
        self.verdicts().filter_map(|(key, v)| match v {
+
            Verdict::Accept(_) => None,
+
            Verdict::Reject => Some(key.into()),
+
        })
    }

-
    pub fn is_quorum_reached(&self, previous: &Identity<Oid>) -> bool {
-
        let votes_for = self
-
            .verdicts
-
            .iter()
-
            .fold(0, |count, (_, verdict)| match verdict {
-
                Some(Verdict::Accept(_)) => count + 1,
-
                Some(Verdict::Reject) => count,
-
                None => count,
-
            });
-
        votes_for >= previous.doc.threshold
+
    pub fn sign<G: Signer>(&self, signer: &G) -> Result<Signature, DocError> {
+
        self.doc.signature_of(signer)
+
    }
+
}
+

+
// Private functions that may not do all the verification. Use with caution.
+
impl Revision {
+
    fn new(
+
        id: RevisionId,
+
        title: String,
+
        description: String,
+
        author: Author,
+
        blob: Oid,
+
        doc: Doc<Verified>,
+
        state: State,
+
        signature: Signature,
+
        parent: Option<RevisionId>,
+
        timestamp: Timestamp,
+
    ) -> Self {
+
        let verdicts = BTreeMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]);
+

+
        Self {
+
            id,
+
            title,
+
            description,
+
            author,
+
            blob,
+
            doc,
+
            state,
+
            verdicts,
+
            parent,
+
            timestamp,
+
        }
    }

-
    fn accept(&mut self, key: PublicKey, signature: Signature) {
-
        self.verdicts.insert(key, Some(Verdict::Accept(signature)));
+
    fn accept(
+
        &mut self,
+
        author: PublicKey,
+
        signature: Signature,
+
        current: &Revision,
+
    ) -> Result<(), ApplyError> {
+
        // Check that this is a valid signature over the new document blob id.
+
        if current
+
            .verify_signature(&author, &signature, self.blob)
+
            .is_err()
+
        {
+
            return Err(ApplyError::InvalidSignature(author, self.blob));
+
        }
+
        if self
+
            .verdicts
+
            .insert(author, Verdict::Accept(signature))
+
            .is_some()
+
        {
+
            return Err(ApplyError::DuplicateVerdict);
+
        }
+
        Ok(())
    }

-
    fn reject(&mut self, key: PublicKey) {
-
        self.verdicts.insert(key, Some(Verdict::Reject));
+
    fn reject(&mut self, key: PublicKey) -> Result<(), ApplyError> {
+
        if self.verdicts.insert(key, Verdict::Reject).is_some() {
+
            return Err(ApplyError::DuplicateVerdict);
+
        }
+
        // Mark as rejected if it's impossible for this revision to be accepted
+
        // with the current delegate set. Note that if the delegate set changes,
+
        // this proposal will be marked as `stale` anyway.
+
        if self.is_active() && self.rejected().count() > self.delegates.len() - self.majority() {
+
            self.state = State::Rejected;
+
        }
+
        Ok(())
    }
}

-
impl<R: ReadRepository> store::Transaction<Proposal, R> {
+
impl<R: ReadRepository> store::Transaction<Identity, R> {
    pub fn accept(
        &mut self,
        revision: RevisionId,
        signature: Signature,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Accept {
+
        self.push(Action::RevisionAccept {
            revision,
            signature,
        })
    }

    pub fn reject(&mut self, revision: RevisionId) -> Result<(), store::Error> {
-
        self.push(Action::Reject { revision })
+
        self.push(Action::RevisionReject { revision })
    }

    pub fn edit(
        &mut self,
+
        revision: RevisionId,
        title: impl ToString,
        description: impl ToString,
    ) -> Result<(), store::Error> {
-
        self.push(Action::Edit {
+
        self.push(Action::RevisionEdit {
+
            revision,
            title: title.to_string(),
            description: description.to_string(),
        })
@@ -600,55 +799,61 @@ impl<R: ReadRepository> store::Transaction<Proposal, R> {
        self.push(Action::RevisionRedact { revision })
    }

-
    pub fn revision(&mut self, current: Oid, proposed: Doc<Verified>) -> Result<(), store::Error> {
-
        self.push(Action::Revision { current, proposed })
-
    }
-

-
    /// Start a proposal revision discussion.
-
    pub fn thread<S: ToString>(
+
    pub fn revision<G: Signer>(
        &mut self,
-
        revision: RevisionId,
-
        body: S,
+
        title: impl ToString,
+
        description: impl ToString,
+
        doc: &Doc<Verified>,
+
        parent: Option<RevisionId>,
+
        signer: &G,
    ) -> Result<(), store::Error> {
-
        self.push(Action::RevisionComment {
-
            revision,
-
            body: body.to_string(),
-
            reply_to: None,
-
        })
-
    }
+
        let (blob, content, signature) = doc.sign(signer).map_err(store::Error::Identity)?;

-
    /// Comment on a proposal revision.
-
    pub fn comment<S: ToString>(
-
        &mut self,
-
        revision: RevisionId,
-
        body: S,
-
        reply_to: thread::CommentId,
-
    ) -> Result<(), store::Error> {
-
        self.push(Action::RevisionComment {
-
            revision,
-
            body: body.to_string(),
-
            reply_to: Some(reply_to),
+
        // Identity document.
+
        self.embed([cob::Embed {
+
            name: String::from("radicle.json"),
+
            content,
+
        }])?;
+

+
        // Revision metadata.
+
        self.push(Action::Revision {
+
            title: title.to_string(),
+
            description: description.to_string(),
+
            blob,
+
            parent,
+
            signature,
        })
    }
}

-
pub struct ProposalMut<'a, 'g, R> {
+
pub struct IdentityMut<'a, R> {
    pub id: ObjectId,

-
    proposal: Proposal,
-
    store: &'g mut Proposals<'a, R>,
+
    identity: Identity,
+
    store: store::Store<'a, Identity, R>,
+
}
+

+
impl<'a, R> fmt::Debug for IdentityMut<'a, R> {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        f.debug_struct("IdentityMut")
+
            .field("id", &self.id)
+
            .field("identity", &self.identity)
+
            .finish()
+
    }
}

-
impl<'a, 'g, R> ProposalMut<'a, 'g, R>
+
impl<'a, R> IdentityMut<'a, R>
where
    R: WriteRepository + cob::Store,
{
-
    pub fn new(id: ObjectId, proposal: Proposal, store: &'g mut Proposals<'a, R>) -> Self {
-
        Self {
-
            id,
-
            proposal,
-
            store,
-
        }
+
    /// Reload the identity data from storage.
+
    pub fn reload(&mut self) -> Result<(), store::Error> {
+
        self.identity = self
+
            .store
+
            .get(&self.id)?
+
            .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
+

+
        Ok(())
    }

    pub fn transaction<G, F>(
@@ -659,162 +864,592 @@ where
    ) -> Result<EntryId, Error>
    where
        G: Signer,
-
        F: FnOnce(&mut Transaction<Proposal, R>) -> Result<(), store::Error>,
+
        F: FnOnce(&mut Transaction<Identity, R>) -> Result<(), store::Error>,
    {
        let mut tx = Transaction::default();
        operations(&mut tx)?;

-
        let (proposal, commit) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
-
        self.proposal = proposal;
+
        let (doc, commit) = tx.commit(message, self.id, &mut self.store, signer)?;
+
        self.identity = doc;

        Ok(commit)
    }

-
    /// Accept a proposal revision.
-
    pub fn accept<G: Signer>(
+
    /// Update the identity by proposing a new revision.
+
    /// If the signer is the only delegate, the revision is accepted automatically.
+
    pub fn update<G: Signer>(
        &mut self,
-
        revision: RevisionId,
-
        signature: Signature,
+
        title: impl ToString,
+
        description: impl ToString,
+
        doc: &Doc<Verified>,
        signer: &G,
-
    ) -> Result<EntryId, Error> {
-
        self.transaction("Accept", signer, |tx| tx.accept(revision, signature))
+
    ) -> Result<Revision, Error> {
+
        let parent = self.current;
+
        let id = self.transaction("Propose revision", signer, |tx| {
+
            tx.revision(title, description, doc, Some(parent), signer)
+
        })?;
+

+
        // SAFETY: Since the revision was just added, it's guaranteed to be there.
+
        Ok(self
+
            .revision(&id)
+
            .expect("IdentityMut::update: revision exists")
+
            .clone())
    }

-
    /// Reject a proposal revision.
-
    pub fn reject<G: Signer>(
-
        &mut self,
-
        revision: RevisionId,
-
        signer: &G,
-
    ) -> Result<EntryId, Error> {
-
        self.transaction("Reject", signer, |tx| tx.reject(revision))
+
    /// Accept an active revision.
+
    pub fn accept<G: Signer>(&mut self, revision: &Revision, signer: &G) -> Result<EntryId, Error> {
+
        let signature = revision.sign(signer)?;
+

+
        self.transaction("Accept revision", signer, |tx| {
+
            tx.accept(revision.id, signature)
+
        })
    }

-
    /// Edit proposal metadata.
-
    pub fn edit<G: Signer>(
+
    /// Reject an active revision.
+
    pub fn reject<G: Signer>(
        &mut self,
-
        title: String,
-
        description: String,
+
        revision: RevisionId,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Edit", signer, |tx| tx.edit(title, description))
-
    }
-

-
    /// Commit a proposal.
-
    pub fn commit<G: Signer>(&mut self, signer: &G) -> Result<EntryId, Error> {
-
        self.transaction("Commit", signer, |tx| tx.push(Action::Commit))
-
    }
-

-
    /// Close a proposal.
-
    pub fn close<G: Signer>(&mut self, signer: &G) -> Result<EntryId, Error> {
-
        self.transaction("Close", signer, |tx| tx.push(Action::Close))
+
        self.transaction("Reject revision", signer, |tx| tx.reject(revision))
    }

-
    /// Comment on a proposal revision.
-
    pub fn comment<G: Signer, S: ToString>(
+
    /// Redact a revision.
+
    pub fn redact<G: Signer>(
        &mut self,
        revision: RevisionId,
-
        body: S,
-
        reply_to: thread::CommentId,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Comment", signer, |tx| tx.comment(revision, body, reply_to))
+
        self.transaction("Redact revision", signer, |tx| tx.redact(revision))
    }

-
    /// Update a proposal with new metadata.
-
    pub fn update<G: Signer>(
+
    /// Edit an active revision's title or description.
+
    pub fn edit<G: Signer>(
        &mut self,
-
        current: impl Into<Oid>,
-
        proposed: Doc<Verified>,
+
        revision: RevisionId,
+
        title: String,
+
        description: String,
        signer: &G,
    ) -> Result<EntryId, Error> {
-
        self.transaction("Add revision", signer, |tx| {
-
            tx.revision(current.into(), proposed)
+
        self.transaction("Edit revision", signer, |tx| {
+
            tx.edit(revision, title, description)
        })
    }
}

-
impl<'a, 'g, R> Deref for ProposalMut<'a, 'g, R> {
-
    type Target = Proposal;
+
impl<'a, R> Deref for IdentityMut<'a, R> {
+
    type Target = Identity;

    fn deref(&self) -> &Self::Target {
-
        &self.proposal
+
        &self.identity
    }
}

-
pub struct Proposals<'a, R> {
-
    raw: store::Store<'a, Proposal, R>,
-
}
+
mod lookup {
+
    use super::*;

-
impl<'a, R> Deref for Proposals<'a, R> {
-
    type Target = store::Store<'a, Proposal, R>;
+
    pub fn revision_mut<'a>(
+
        revisions: &'a mut BTreeMap<RevisionId, Option<Revision>>,
+
        revision: &RevisionId,
+
    ) -> Result<Option<&'a mut Revision>, ApplyError> {
+
        match revisions.get_mut(revision) {
+
            Some(Some(revision)) => Ok(Some(revision)),
+
            // Redacted.
+
            Some(None) => Ok(None),
+
            // Missing. Causal error.
+
            None => Err(ApplyError::Missing(*revision)),
+
        }
+
    }

-
    fn deref(&self) -> &Self::Target {
-
        &self.raw
+
    pub fn revision<'a>(
+
        revisions: &'a BTreeMap<RevisionId, Option<Revision>>,
+
        revision: &RevisionId,
+
    ) -> Result<Option<&'a Revision>, ApplyError> {
+
        match revisions.get(revision) {
+
            Some(Some(revision)) => Ok(Some(revision)),
+
            // Redacted.
+
            Some(None) => Ok(None),
+
            // Missing. Causal error.
+
            None => Err(ApplyError::Missing(*revision)),
+
        }
    }
}

-
impl<'a, R: WriteRepository> Proposals<'a, R>
-
where
-
    R: WriteRepository + cob::Store,
-
{
-
    /// Open a proposals store.
-
    pub fn open(repository: &'a R) -> Result<Self, store::Error> {
-
        let raw = store::Store::open(repository)?;
+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use qcheck_macros::quickcheck;
+
    use radicle_crypto::test::signer::MockSigner;
+
    use radicle_crypto::Signer as _;
+

+
    use crate::crypto::PublicKey;
+
    use crate::identity::Visibility;
+
    use crate::rad;
+
    use crate::storage::git::Storage;
+
    use crate::storage::ReadStorage;
+
    use crate::test::fixtures;
+
    use crate::test::setup::{Network, NodeWithRepo};
+

+
    use super::*;
+
    use crate::identity::did::Did;
+
    use crate::identity::doc::PayloadId;

-
        Ok(Self { raw })
+
    #[quickcheck]
+
    fn prop_json_eq_str(pk: PublicKey, proj: Id, did: Did) {
+
        let json = serde_json::to_string(&pk).unwrap();
+
        assert_eq!(format!("\"{pk}\""), json);
+

+
        let json = serde_json::to_string(&proj).unwrap();
+
        assert_eq!(format!("\"{}\"", proj.urn()), json);
+

+
        let json = serde_json::to_string(&did).unwrap();
+
        assert_eq!(format!("\"{did}\""), json);
    }

-
    /// Create a proposal.
-
    pub fn create<'g, G: Signer>(
-
        &'g mut self,
-
        title: impl ToString,
-
        description: impl ToString,
-
        current: impl Into<Oid>,
-
        proposed: Doc<Verified>,
-
        signer: &G,
-
    ) -> Result<ProposalMut<'a, 'g, R>, Error> {
-
        let (id, proposal) =
-
            Transaction::initial("Create proposal", &mut self.raw, signer, |tx| {
-
                tx.revision(current.into(), proposed)?;
-
                tx.edit(title, description)?;
+
    #[test]
+
    fn test_identity_updates() {
+
        let NodeWithRepo { node, repo } = NodeWithRepo::default();
+
        let bob = MockSigner::default();
+
        let signer = &node.signer;
+
        let mut identity = Identity::load_mut(&*repo).unwrap();
+
        let mut doc = identity.doc().clone();
+
        let title = "Identity update";
+
        let description = "";
+
        let r0 = identity.current;
+

+
        // The initial state is accepted.
+
        assert!(identity.current().is_accepted());
+
        // Using an identical document to the current one fails.
+
        identity
+
            .update(title, description, &doc, signer)
+
            .unwrap_err();
+
        assert_eq!(identity.current, r0);
+

+
        // Change threshold to `2`, even though there's only one delegate. This should
+
        // fail as it makes the master branch immutable.
+
        doc.threshold = 2;
+
        identity
+
            .update(title, description, &doc, signer)
+
            .unwrap_err();
+
        assert_eq!(identity.current, r0);
+
        // Let's add another delegate.
+
        doc.delegate(bob.public_key());
+
        // The update should go through now.
+
        let r1 = identity
+
            .update(title, description, &doc, signer)
+
            .unwrap()
+
            .id;
+
        assert!(identity.revision(&r1).unwrap().is_accepted());
+
        assert_eq!(identity.current, r1);
+
        // With two delegates now, we need two signatures for any update to go through.
+
        // So this next update shouldn't be accepted as canonical until the second delegate
+
        // signs it.
+
        doc.visibility = Visibility::private([]);
+
        let r2 = identity.update(title, description, &doc, signer).unwrap();
+
        // R1 is still the head.
+
        assert_eq!(identity.current, r1);
+
        assert_eq!(r2.state, State::Active);
+
        assert_eq!(repo.canonical_identity_head().unwrap(), r1);
+
        assert_eq!(repo.identity_doc().unwrap().visibility, Visibility::Public);
+
        // Now let's add a signature on R2 from Bob.
+
        identity.accept(&r2, &bob).unwrap();
+

+
        // R2 is now the head.
+
        assert_eq!(identity.current, r2.id);
+
        assert_eq!(identity.revision(&r2.id).unwrap().state, State::Accepted);
+
        assert_eq!(repo.canonical_identity_head().unwrap(), r2.id);
+
        assert_eq!(
+
            repo.canonical_identity_doc().unwrap().visibility,
+
            Visibility::private([])
+
        );
+
    }

-
                Ok(())
-
            })?;
+
    #[test]
+
    fn test_identity_update_rejected() {
+
        let NodeWithRepo { node, repo } = NodeWithRepo::default();
+
        let bob = MockSigner::default();
+
        let eve = MockSigner::default();
+
        let signer = &node.signer;
+
        let mut identity = Identity::load_mut(&*repo).unwrap();
+
        let mut doc = identity.doc().clone();
+
        let title = "Identity update";
+
        let description = "";
+

+
        // Let's add another delegate.
+
        doc.delegate(bob.public_key());
+
        let r1 = identity
+
            .update(title, description, &doc, signer)
+
            .unwrap()
+
            .id;
+
        assert_eq!(identity.current, r1);
+

+
        doc.visibility = Visibility::private([]);
+
        let r2 = identity
+
            .update("Make private", description, &doc, &node.signer)
+
            .unwrap();
+

+
        // 1/2 rejected means that we can never reach the required 2/2 votes.
+
        identity.reject(r2.id, &bob).unwrap();
+
        let r2 = identity.revision(&r2.id).unwrap();
+
        assert_eq!(r2.state, State::Rejected);
+

+
        // Now let's add another delegate.
+
        doc.delegate(eve.public_key());
+
        let r3 = identity
+
            .update("Add Eve", description, &doc, &node.signer)
+
            .unwrap();
+
        let _ = identity.accept(&r3, &bob).unwrap();
+
        assert_eq!(identity.current, r3.id);
+

+
        doc.visibility = Visibility::Public;
+
        let r3 = identity
+
            .update("Make public", description, &doc, &node.signer)
+
            .unwrap();
+

+
        // 1/3 rejected means that we can still reach the 2/3 required votes.
+
        identity.reject(r3.id, &bob).unwrap();
+
        let r3 = identity.revision(&r3.id).unwrap().clone();
+
        assert_eq!(r3.state, State::Active); // Still active.
+

+
        // 2/3 rejected means that we can no longer reach the 2/3 required votes.
+
        identity.reject(r3.id, &eve).unwrap();
+
        let r3 = identity.revision(&r3.id).unwrap();
+
        assert_eq!(r3.state, State::Rejected);
+
    }

-
        Ok(ProposalMut::new(id, proposal, self))
+
    #[test]
+
    fn test_identity_updates_concurrent() {
+
        let network = Network::default();
+
        let alice = &network.alice;
+
        let bob = &network.bob;
+

+
        let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap();
+
        let mut alice_doc = alice_identity.doc().clone();
+

+
        alice_doc.delegate(bob.signer.public_key());
+
        let a1 = alice_identity
+
            .update("Add Bob", "", &alice_doc, &alice.signer)
+
            .unwrap()
+
            .id;
+

+
        bob.repo.fetch(alice);
+

+
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
+
        let bob_doc = bob_identity.doc().clone();
+
        assert!(bob_doc.is_delegate(bob.signer.public_key()));
+

+
        // Alice changes the document without making Bob aware.
+
        alice_doc.visibility = Visibility::private([]);
+
        let a2 = alice_identity
+
            .update("Change visibility", "", &alice_doc, &alice.signer)
+
            .unwrap();
+
        // Bob makes the same change without knowing Alice already did.
+
        let b1 = bob_identity
+
            .update("Make private", "", &alice_doc, &bob.signer)
+
            .unwrap()
+
            .id;
+

+
        // Bob gets Alice's data.
+
        bob.repo.fetch(alice);
+
        bob_identity.reload().unwrap();
+
        assert_eq!(bob_identity.current, a1);
+

+
        // Alice gets Bob's data.
+
        // There's not enough votes for either of these proposals to pass.
+
        alice.repo.fetch(bob);
+
        alice_identity.reload().unwrap();
+
        assert_eq!(alice_identity.current, a1);
+
        assert_eq!(bob_identity.revision(&a2.id).unwrap().state, State::Active);
+
        assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Active);
+

+
        // Now Bob accepts Alice's proposal. This voids his own.
+
        bob_identity.accept(&a2, &bob.signer).unwrap();
+
        assert_eq!(bob_identity.current, a2.id);
+
        assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted);
+
        assert_eq!(
+
            bob_identity.revision(&a2.id).unwrap().state,
+
            State::Accepted
+
        );
+
        assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Stale);
    }

-
    /// Get a proposal.
-
    pub fn get(&self, id: &ObjectId) -> Result<Option<Proposal>, store::Error> {
-
        self.raw.get(id)
+
    #[test]
+
    fn test_identity_redact_revision() {
+
        let network = Network::default();
+
        let alice = &network.alice;
+
        let bob = &network.bob;
+
        let eve = &network.eve;
+

+
        let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap();
+
        let mut alice_doc = alice_identity.doc().clone();
+

+
        alice_doc.delegate(bob.signer.public_key());
+
        let a0 = alice_identity.root;
+
        let a1 = alice_identity
+
            .update("Add Bob", "Eh.", &alice_doc, &alice.signer)
+
            .unwrap()
+
            .id;
+

+
        alice_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
+
        let a2 = alice_identity
+
            .update("Change visibility", "Eh.", &alice_doc, &alice.signer)
+
            .unwrap();
+

+
        bob.repo.fetch(alice);
+
        let a3 = alice_identity.redact(a2.id, &alice.signer).unwrap();
+
        assert!(alice_identity.revision(&a1).is_some());
+
        assert_eq!(alice_identity.timeline, vec![a0, a1, a2.id, a3]);
+

+
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
+
        let b1 = bob_identity.accept(&a2, &bob.signer).unwrap();
+

+
        assert_eq!(bob_identity.timeline, vec![a0, a1, a2.id, b1]);
+
        assert_eq!(
+
            bob_identity.revision(&a2.id).unwrap().state,
+
            State::Accepted
+
        );
+
        bob.repo.fetch(alice);
+
        bob_identity.reload().unwrap();
+

+
        assert_eq!(bob_identity.timeline, vec![a0, a1, a2.id, a3, b1]);
+
        assert_eq!(bob_identity.revision(&a2.id), None);
+
        assert_eq!(bob_identity.current, a1);
    }

-
    /// Get a proposal mutably.
-
    pub fn get_mut<'g>(
-
        &'g mut self,
-
        id: &ObjectId,
-
    ) -> Result<ProposalMut<'a, 'g, R>, store::Error> {
-
        let proposal = self
-
            .raw
-
            .get(id)?
-
            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
+
    #[test]
+
    fn test_identity_remove_delegate_concurrent() {
+
        let network = Network::default();
+
        let alice = &network.alice;
+
        let bob = &network.bob;
+
        let eve = &network.eve;
+

+
        let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap();
+
        let mut alice_doc = alice_identity.doc().clone();
+

+
        alice_doc.delegate(bob.signer.public_key());
+
        alice_doc.delegate(eve.signer.public_key());
+
        let a0 = alice_identity.root;
+
        let a1 = alice_identity
+
            .update("Add Bob and Eve", "Eh.", &alice_doc, &alice.signer)
+
            .unwrap()
+
            .id;
+

+
        alice_doc.rescind(eve.signer.public_key()).unwrap();
+
        let a2 = alice_identity
+
            .update("Remove Eve", "", &alice_doc, &alice.signer)
+
            .unwrap();
+

+
        bob.repo.fetch(eve);
+
        bob.repo.fetch(alice);
+
        eve.repo.fetch(bob);
+

+
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
+
        let b1 = bob_identity.accept(&a2, &bob.signer).unwrap();
+
        assert_eq!(bob_identity.current, a2.id);
+

+
        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
+
        let mut eve_doc = eve_identity.doc().clone();
+
        eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
+
        let e1 = eve_identity
+
            .update("Change visibility", "", &eve_doc, &eve.signer)
+
            .unwrap();
+

+
        eve.repo.fetch(bob);
+
        eve_identity.reload().unwrap();
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2.id, b1, e1.id]);
+
        assert!(!eve_identity.is_delegate(eve.signer.public_key()));
+
    }

-
        Ok(ProposalMut {
-
            id: *id,
-
            proposal,
-
            store: self,
-
        })
+
    #[test]
+
    fn test_identity_reject_concurrent() {
+
        let network = Network::default();
+
        let alice = &network.alice;
+
        let bob = &network.bob;
+
        let eve = &network.eve;
+

+
        let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap();
+
        let mut alice_doc = alice_identity.doc().clone();
+

+
        alice_doc.delegate(bob.signer.public_key());
+
        alice_doc.delegate(eve.signer.public_key());
+
        let a0 = alice_identity.root;
+
        let a1 = alice_identity
+
            .update("Add Bob and Eve", "Eh.", &alice_doc, &alice.signer)
+
            .unwrap()
+
            .id;
+

+
        alice_doc.visibility = Visibility::private([]);
+
        let a2 = alice_identity
+
            .update("Change visibility", "", &alice_doc, &alice.signer)
+
            .unwrap();
+

+
        bob.repo.fetch(eve);
+
        bob.repo.fetch(alice);
+
        eve.repo.fetch(bob);
+

+
        // Bob accepts alice's revision.
+
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
+
        let b1 = bob_identity.accept(&a2, &bob.signer).unwrap();
+

+
        // Eve rejects the revision, not knowing.
+
        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
+
        let e1 = eve_identity.reject(a2.id, &eve.signer).unwrap();
+

+
        // Then she submits a new revision.
+
        let mut eve_doc = eve_identity.doc().clone();
+
        eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
+
        let e2 = eve_identity
+
            .update("Change visibility", "", &eve_doc, &eve.signer)
+
            .unwrap();
+

+
        // Though the rules are that you cannot reject an already accepted revision,
+
        // since this update was done concurrently there was no way of knowing. Therefore,
+
        // an error shouldn't be returned. We simply ignore the rejection.
+

+
        eve.repo.fetch(bob);
+
        eve_identity.reload().unwrap();
+
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2.id, b1, e1, e2.id]);
+

+
        // Her revision is there, although stale, since another revision was accepted since.
+
        // However, it wasn't pruned, even though rejecting an accepted revision is an error.
+
        let e2 = eve_identity.revision(&e2.id).unwrap();
+
        assert_eq!(e2.state, State::Stale);
    }
-
}

-
#[cfg(test)]
-
mod test {
-
    use super::State;
+
    #[test]
+
    fn test_identity_updates_concurrent_outdated() {
+
        let network = Network::default();
+
        let alice = &network.alice;
+
        let bob = &network.bob;
+
        let eve = &network.eve;
+

+
        let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap();
+
        let mut alice_doc = alice_identity.doc().clone();
+

+
        alice.repo.fetch(bob);
+
        alice.repo.fetch(eve);
+
        alice_doc.delegate(bob.signer.public_key());
+
        alice_doc.delegate(eve.signer.public_key());
+
        let a0 = alice_identity.root;
+
        let a1 = alice_identity
+
            .update("Add Bob and Eve", "", &alice_doc, &alice.signer)
+
            .unwrap();
+

+
        bob.repo.fetch(alice);
+
        eve.repo.fetch(bob); // TODO: Why is this needed for it not to panic?
+
        eve.repo.fetch(alice);
+

+
        let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap();
+
        let mut bob_doc = bob_identity.doc().clone();
+
        assert!(bob_doc.is_delegate(bob.signer.public_key()));
+

+
        //  a2 e1
+
        //  | /
+
        //  b1
+
        //  |
+
        //  a1
+
        //  |
+
        //  a0
+

+
        // Bob and Alice change the document visibility. Eve is not aware.
+
        bob_doc.visibility = Visibility::private([]);
+
        let b1 = bob_identity
+
            .update("Change visibility #1", "", &bob_doc, &bob.signer)
+
            .unwrap();
+
        alice.repo.fetch(bob);
+
        eve.repo.fetch(bob);
+

+
        // In the meantime, Eve does the same thing on her side.
+
        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
+
        let mut eve_doc = eve_identity.doc().clone();
+
        eve_doc.visibility = Visibility::private([]);
+
        let e1 = eve_identity
+
            .update("Change visibility #2", "Woops", &eve_doc, &eve.signer)
+
            .unwrap();
+
        assert_eq!(eve_identity.revisions().count(), 4);
+
        assert_eq!(e1.state, State::Active);
+

+
        let a2 = alice_identity.accept(&b1, &alice.signer).unwrap();
+

+
        eve.repo.fetch(alice);
+
        eve_identity.reload().unwrap();
+

+
        assert_eq!(eve_identity.timeline, vec![a0, a1.id, b1.id, a2, e1.id]);
+
        assert_eq!(eve_identity.revision(&e1.id).unwrap().state, State::Stale);
+
    }

    #[test]
-
    fn test_ordering() {
-
        assert!(State::Committed > State::Closed);
-
        assert!(State::Committed > State::Open);
-
        assert!(State::Closed > State::Open);
+
    fn test_valid_identity() {
+
        let tempdir = tempfile::tempdir().unwrap();
+
        let mut rng = fastrand::Rng::new();
+

+
        let alice = MockSigner::new(&mut rng);
+
        let bob = MockSigner::new(&mut rng);
+
        let eve = MockSigner::new(&mut rng);
+

+
        let storage = Storage::open(tempdir.path().join("storage")).unwrap();
+
        let (id, _, _, _) =
+
            fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap();
+

+
        // Bob and Eve fork the project from Alice.
+
        rad::fork_remote(id, alice.public_key(), &bob, &storage).unwrap();
+
        rad::fork_remote(id, alice.public_key(), &eve, &storage).unwrap();
+

+
        let repo = storage.repository(id).unwrap();
+
        let mut identity = Identity::load_mut(&repo).unwrap();
+
        let mut doc = identity.doc().clone();
+
        let prj = doc.project().unwrap();
+

+
        // Make a change to the description and sign it.
+
        let desc = prj.description().to_owned() + "!";
+
        let prj = prj.update(None, desc, None).unwrap();
+
        doc.payload.insert(PayloadId::project(), prj.clone().into());
+
        identity
+
            .update("Update description", "", &doc, &alice)
+
            .unwrap();
+

+
        // Add Bob as a delegate, and sign it.
+
        doc.delegate(bob.public_key());
+
        doc.threshold = 2;
+
        identity.update("Add bob", "", &doc, &alice).unwrap();
+

+
        // Add Eve as a delegate.
+
        doc.delegate(eve.public_key());
+

+
        // Update with both Bob and Alice's signature.
+
        let revision = identity
+
            .update("Add eve", "", &doc, &alice)
+
            .unwrap()
+
            .clone();
+
        identity.accept(&revision, &bob).unwrap();
+

+
        // Update description again with signatures by Eve and Bob.
+
        let desc = prj.description().to_owned() + "?";
+
        let prj = prj.update(None, desc, None).unwrap();
+
        doc.payload.insert(PayloadId::project(), prj.into());
+

+
        let revision = identity
+
            .update("Update description again", "Bob's repository", &doc, &bob)
+
            .unwrap();
+
        identity.accept(&revision, &eve).unwrap();
+

+
        let identity: Identity = Identity::load(&repo).unwrap();
+
        let root = repo.identity_root().unwrap();
+
        let doc = repo.identity_doc_at(revision.id).unwrap();
+

+
        assert_eq!(identity.signatures().count(), 2);
+
        assert_eq!(identity.revisions().count(), 5);
+
        assert_eq!(identity.id(), id);
+
        assert_eq!(identity.root().id, root);
+
        assert_eq!(identity.current().blob, doc.blob);
+
        assert_eq!(identity.current().description.as_str(), "Bob's repository");
+
        assert_eq!(identity.head(), revision.id);
+
        assert_eq!(identity.doc(), &*doc);
+
        assert_eq!(
+
            identity.doc().project().unwrap().description(),
+
            "Acme's repository!?"
+
        );
+

+
        assert_eq!(doc.project().unwrap().description(), "Acme's repository!?");
    }
}
modified radicle/src/cob/issue.rs
@@ -14,10 +14,9 @@ use crate::cob::thread;
use crate::cob::thread::{Comment, CommentId, Thread};
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName};
use crate::crypto::Signer;
-
use crate::git;
use crate::identity::doc::{Doc, DocError};
use crate::prelude::{Did, ReadRepository, Verified};
-
use crate::storage::WriteRepository;
+
use crate::storage::{RepositoryError, WriteRepository};

/// Issue operation.
pub type Op = cob::Op<Action>;
@@ -48,6 +47,9 @@ pub enum Error {
    /// Title is invalid.
    #[error("invalid title: {0:?}")]
    InvalidTitle(String),
+
    /// The identity doc is missing.
+
    #[error("identity document missing")]
+
    MissingIdentity,
    /// General error initializing an issue.
    #[error("initialization failed: {0}")]
    Init(&'static str),
@@ -127,6 +129,7 @@ impl store::Cob for Issue {
    }

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
        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`"));
@@ -136,17 +139,17 @@ impl store::Cob for Issue {
        let mut issue = Issue::new(thread);

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

    fn op<R: ReadRepository>(&mut self, op: Op, repo: &R) -> Result<(), Error> {
-
        let doc = repo.identity_doc_at(op.identity)?.verified()?;
+
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
        for action in op.actions {
            match self.authorization(&action, &op.author, &doc)? {
                Authorization::Allow => {
-
                    self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
+
                    self.action(action, op.id, op.author, op.timestamp, &doc, repo)?;
                }
                Authorization::Deny => {
                    return Err(Error::NotAuthorized(op.author, action));
@@ -312,7 +315,7 @@ impl Issue {
        entry: EntryId,
        author: ActorId,
        timestamp: Timestamp,
-
        _identity: git::Oid,
+
        _doc: &Doc<Verified>,
        _repo: &R,
    ) -> Result<(), Error> {
        match action {
@@ -658,8 +661,9 @@ where
    R: ReadRepository + cob::Store,
{
    /// Open an issues store.
-
    pub fn open(repository: &'a R) -> Result<Self, store::Error> {
-
        let raw = store::Store::open(repository)?;
+
    pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
+
        let identity = repository.identity_head()?;
+
        let raw = store::Store::open(repository)?.identity(identity);

        Ok(Self { raw })
    }
@@ -1546,7 +1550,7 @@ mod test {

        let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
        let eve = MockSigner::default();
-
        let identity = repo.identity().unwrap().head;
+
        let identity = repo.identity().unwrap().head();
        let missing = arbitrary::oid();
        let type_name = Issue::type_name().clone();
        let mut issues = Issues::open(&*repo).unwrap();
@@ -1578,7 +1582,7 @@ mod test {
        let contents = NonEmpty::new(action);
        let invalid = repo
            .store(
-
                identity,
+
                Some(identity),
                vec![],
                &eve,
                cob::change::Template {
modified radicle/src/cob/op.rs
@@ -6,7 +6,9 @@ use radicle_cob::history::{Entry, EntryId};
use radicle_crypto::PublicKey;

use crate::cob::Timestamp;
-
use crate::git;
+
use crate::identity::DocAt;
+
use crate::storage::ReadRepository;
+
use crate::{git, identity};

/// The author of an [`Op`].
pub type ActorId = PublicKey;
@@ -37,7 +39,7 @@ pub struct Op<A> {
    /// Parent operations.
    pub parents: Vec<EntryId>,
    /// Head of identity document committed to by this operation.
-
    pub identity: git::Oid,
+
    pub identity: Option<git::Oid>,
    /// Object manifest.
    pub manifest: Manifest,
}
@@ -60,7 +62,7 @@ impl<A> Op<A> {
        actions: impl Into<NonEmpty<A>>,
        author: ActorId,
        timestamp: impl Into<Timestamp>,
-
        identity: git::Oid,
+
        identity: Option<git::Oid>,
        manifest: Manifest,
    ) -> Self {
        Self {
@@ -77,6 +79,16 @@ impl<A> Op<A> {
    pub fn id(&self) -> EntryId {
        self.id
    }
+

+
    pub fn identity_doc<R: ReadRepository>(
+
        &self,
+
        repo: &R,
+
    ) -> Result<Option<DocAt>, identity::DocError> {
+
        match self.identity {
+
            None => Ok(None),
+
            Some(head) => repo.identity_doc_at(head).map(Some),
+
        }
+
    }
}

impl From<Entry> for Op<Vec<u8>> {
@@ -101,7 +113,7 @@ where

    fn try_from(entry: &'a Entry) -> Result<Self, Self::Error> {
        let id = *entry.id();
-
        let identity = *entry.resource();
+
        let identity = entry.resource().copied();
        let actions: Vec<_> = entry
            .contents()
            .iter()
modified radicle/src/cob/patch.rs
@@ -10,6 +10,7 @@ use amplify::Wrapper;
use once_cell::sync::Lazy;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
+
use storage::RepositoryError;
use thiserror::Error;

use crate::cob;
@@ -22,7 +23,6 @@ use crate::cob::thread::{Comment, CommentId, Reactions};
use crate::cob::{op, store, ActorId, Embed, EntryId, ObjectId, TypeName, Uri};
use crate::crypto::{PublicKey, Signer};
use crate::git;
-
use crate::identity;
use crate::identity::doc::DocError;
use crate::identity::PayloadError;
use crate::prelude::*;
@@ -99,6 +99,9 @@ pub enum Error {
    /// Error loading the identity document committed to by an operation.
    #[error("identity doc failed to load: {0}")]
    Doc(#[from] DocError),
+
    /// Identity document is missing.
+
    #[error("missing identity docuemnt")]
+
    MissingIdentity,
    /// Error loading the document payload.
    #[error("payload failed to load: {0}")]
    Payload(#[from] PayloadError),
@@ -340,7 +343,7 @@ pub enum MergeTarget {

impl MergeTarget {
    /// Get the head of the target branch.
-
    pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, identity::IdentityError> {
+
    pub fn head<R: ReadRepository>(&self, repo: &R) -> Result<git::Oid, RepositoryError> {
        match self {
            MergeTarget::Delegates => {
                let (_, target) = repo.head()?;
@@ -1067,6 +1070,7 @@ impl store::Cob for Patch {
    }

    fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
+
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
        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`"));
@@ -1083,7 +1087,6 @@ impl store::Cob for Patch {
            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)?;
@@ -1095,7 +1098,7 @@ impl store::Cob for Patch {
        debug_assert!(!self.timeline.contains(&op.id));
        self.timeline.push(op.id);

-
        let doc = repo.identity_doc_at(op.identity)?.verified()?;
+
        let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;

        for action in op.actions {
            match self.authorization(&action, &op.author, &doc)? {
@@ -2162,9 +2165,10 @@ impl<'a, R> Patches<'a, R>
where
    R: ReadRepository + cob::Store + 'static,
{
-
    /// Open an patches store.
-
    pub fn open(repository: &'a R) -> Result<Self, store::Error> {
-
        let raw = store::Store::open(repository)?;
+
    /// Open a patches store.
+
    pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
+
        let identity = repository.identity_head()?;
+
        let raw = store::Store::open(repository)?.identity(identity);

        Ok(Self { raw })
    }
@@ -2338,6 +2342,7 @@ mod test {
    use super::*;
    use crate::cob::test::Actor;
    use crate::crypto::test::signer::MockSigner;
+
    use crate::identity;
    use crate::test;
    use crate::test::arbitrary;
    use crate::test::arbitrary::gen;
modified radicle/src/cob/store.rs
@@ -83,7 +83,7 @@ pub enum Error {
    #[error("remove error: {0}")]
    Remove(#[from] cob::error::Remove),
    #[error(transparent)]
-
    Identity(#[from] identity::IdentityError),
+
    Identity(#[from] identity::doc::DocError),
    #[error(transparent)]
    Serialize(#[from] serde_json::Error),
    #[error("object `{1}` of type `{0}` was not found")]
@@ -100,7 +100,7 @@ pub enum Error {

/// Storage for collaborative objects of a specific type `T` in a single repository.
pub struct Store<'a, T, R> {
-
    identity: git::Oid,
+
    identity: Option<git::Oid>,
    repo: &'a R,
    witness: PhantomData<T>,
}
@@ -111,17 +111,24 @@ impl<'a, T, R> AsRef<R> for Store<'a, T, R> {
    }
}

-
impl<'a, T, R: ReadRepository> Store<'a, T, R> {
+
impl<'a, T, R: ReadRepository + cob::Store> Store<'a, T, R> {
    /// Open a new generic store.
    pub fn open(repo: &'a R) -> Result<Self, Error> {
-
        let identity = repo.identity()?;
-

        Ok(Self {
            repo,
-
            identity: identity.head,
+
            identity: None,
            witness: PhantomData,
        })
    }
+

+
    /// Return a new store with the attached identity.
+
    pub fn identity(self, identity: git::Oid) -> Self {
+
        Self {
+
            repo: self.repo,
+
            witness: self.witness,
+
            identity: Some(identity),
+
        }
+
    }
}

impl<'a, T, R> Store<'a, T, R>
modified radicle/src/cob/test.rs
@@ -28,7 +28,7 @@ use super::thread;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryBuilder<T> {
    history: History,
-
    resource: Oid,
+
    resource: Option<Oid>,
    time: Timestamp,
    witness: PhantomData<T>,
}
@@ -59,7 +59,7 @@ where
    T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
{
    pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
-
        let resource = arbitrary::oid();
+
        let resource = Some(arbitrary::oid());
        let revision = arbitrary::oid();
        let (contents, oids): (Vec<Vec<u8>>, Vec<Oid>) = actions
            .iter()
@@ -163,7 +163,7 @@ impl<G: Signer> Actor<G> {
    pub fn op_with<T: Cob>(
        &mut self,
        actions: impl IntoIterator<Item = T::Action>,
-
        identity: Oid,
+
        identity: Option<Oid>,
        timestamp: Timestamp,
    ) -> Op<T::Action>
    where
@@ -201,7 +201,7 @@ impl<G: Signer> Actor<G> {
        let identity = arbitrary::oid();
        let timestamp = Timestamp::now();

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

    /// Get the actor's DID.
modified radicle/src/cob/thread.rs
@@ -30,6 +30,9 @@ pub enum Error {
    /// that hasn't happened yet.
    #[error("causal dependency {0:?} missing")]
    Missing(EntryId),
+
    /// The identity doc is missing.
+
    #[error("identity document missing")]
+
    MissingIdentity,
    /// Error with comment operation.
    #[error("comment {0} is invalid")]
    Comment(EntryId),
@@ -378,6 +381,7 @@ impl cob::store::Cob for Thread {
        let author = op.author;
        let entry = op.id;
        let timestamp = op.timestamp;
+
        let identity = op.identity.ok_or(Error::MissingIdentity)?;
        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"));
@@ -396,14 +400,15 @@ impl cob::store::Cob for Thread {
        )?;

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

    fn op<R: ReadRepository>(&mut self, op: Op<Action>, repo: &R) -> Result<(), Error> {
+
        let identity = op.identity.ok_or(Error::MissingIdentity)?;
        for action in op.actions {
-
            self.action(action, op.id, op.author, op.timestamp, op.identity, repo)?;
+
            self.action(action, op.id, op.author, op.timestamp, identity, repo)?;
        }
        Ok(())
    }
modified radicle/src/git.rs
@@ -110,12 +110,12 @@ pub fn version() -> Result<Version, VersionError> {
pub enum RefError {
    #[error("ref name is not valid UTF-8")]
    InvalidName,
-
    #[error("unexpected symbolic ref: {0}")]
-
    Symbolic(RefString),
    #[error("unexpected unqualified ref: {0}")]
    Unqualified(RefString),
    #[error("invalid ref format: {0}")]
    Format(#[from] format::Error),
+
    #[error("reference has no target")]
+
    NoTarget,
    #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")]
    MissingNamespace(format::RefString),
    #[error("ref name contains invalid namespace identifier '{name}'")]
@@ -124,6 +124,8 @@ pub enum RefError {
        #[source]
        err: Box<dyn std::error::Error + Send + Sync + 'static>,
    },
+
    #[error(transparent)]
+
    Other(#[from] git2::Error),
}

#[derive(thiserror::Error, Debug)]
@@ -142,9 +144,7 @@ pub mod refs {
    pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
        let name = r.name().ok_or(RefError::InvalidName)?;
        let refstr = RefStr::try_from_str(name)?;
-
        let target = r
-
            .target()
-
            .ok_or_else(|| RefError::Symbolic(refstr.to_owned()))?;
+
        let target = r.resolve()?.target().ok_or(RefError::NoTarget)?;
        let qualified = Qualified::from_refstr(refstr)
            .ok_or_else(|| RefError::Unqualified(refstr.to_owned()))?;

modified radicle/src/identity.rs
@@ -1,301 +1,11 @@
+
#![warn(clippy::unwrap_used)]
pub mod did;
pub mod doc;
pub mod project;

-
use std::collections::HashMap;
-

-
use radicle_git_ext::Oid;
-
use thiserror::Error;
-

-
use crate::crypto;
-
use crate::crypto::{Signature, Verified};
-
use crate::git;
-
use crate::storage;
-
use crate::storage::git::QuorumError;
-
use crate::storage::{refs, ReadRepository, RemoteId};
-

pub use crypto::PublicKey;
pub use did::Did;
-
pub use doc::{Doc, Id, IdError, PayloadError, Visibility};
+
pub use doc::{Doc, DocAt, DocError, Id, IdError, PayloadError, Visibility};
pub use project::Project;

-
/// Untrusted, well-formed input.
-
#[derive(Clone, Copy, Debug)]
-
pub struct Untrusted;
-
/// Signed by quorum of the previous delegation.
-
#[derive(Clone, Copy, Debug)]
-
pub struct Trusted;
-

-
#[derive(Error, Debug)]
-
pub enum IdentityError {
-
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
-
    #[error("git: {0}")]
-
    GitExt(#[from] git::Error),
-
    #[error("identity branches diverge from each other")]
-
    BranchesDiverge,
-
    #[error("root hash `{0}` does not match project")]
-
    MismatchedRoot(Oid),
-
    #[error("the identity branch is missing")]
-
    MissingBranch,
-
    #[error("the document root is missing")]
-
    MissingRoot,
-
    #[error("root commit is missing one or more delegate signatures")]
-
    MissingRootSignatures,
-
    #[error("quorum failed: {0}")]
-
    Quorum(#[from] QuorumError),
-
    #[error(transparent)]
-
    Payload(#[from] PayloadError),
-
    #[error("commit signature for {0} is invalid: {1}")]
-
    InvalidSignature(PublicKey, crypto::Error),
-
    #[error("threshold not reached: {0} signatures for a threshold of {1}")]
-
    ThresholdNotReached(usize, usize),
-
    #[error("identity document error: {0}")]
-
    Doc(#[from] doc::DocError),
-
    #[error(transparent)]
-
    Refs(#[from] refs::Error),
-
    #[error(transparent)]
-
    Storage(#[from] storage::Error),
-
}
-

-
impl IdentityError {
-
    /// Whether this error is caused by something not being found.
-
    pub fn is_not_found(&self) -> bool {
-
        match self {
-
            Self::Doc(doc) => doc.is_not_found(),
-
            Self::Refs(refs) => refs.is_not_found(),
-
            _ => false,
-
        }
-
    }
-
}
-

-
#[derive(Clone, Debug, PartialEq, Eq)]
-
pub struct Identity<I> {
-
    /// The head of the identity branch. This points to a commit that
-
    /// contains the current document blob.
-
    pub head: Oid,
-
    /// The canonical identifier for this identity.
-
    /// This is the object id of the initial document blob.
-
    pub root: I,
-
    /// The object id of the current document blob.
-
    pub current: Oid,
-
    /// Revision number. The initial document has a revision of `0`.
-
    pub revision: u32,
-
    /// The current document.
-
    pub doc: Doc<Verified>,
-
    /// Signatures over this identity.
-
    pub signatures: HashMap<PublicKey, Signature>,
-
}
-

-
impl Identity<Oid> {
-
    pub fn verified(self, id: doc::Id) -> Result<Identity<doc::Id>, IdentityError> {
-
        // The root hash must be equal to the id.
-
        if self.root != *id {
-
            return Err(IdentityError::MismatchedRoot(self.root));
-
        }
-

-
        Ok(Identity {
-
            root: id,
-
            head: self.head,
-
            current: self.current,
-
            revision: self.revision,
-
            doc: self.doc,
-
            signatures: self.signatures,
-
        })
-
    }
-
}
-

-
impl Identity<Untrusted> {
-
    pub fn load<R: ReadRepository>(
-
        remote: &RemoteId,
-
        repo: &R,
-
    ) -> Result<Identity<Oid>, IdentityError> {
-
        let head = Doc::<Untrusted>::head(remote, repo)?;
-

-
        Self::load_at(head, repo)
-
    }
-

-
    pub fn load_at<R: ReadRepository>(head: Oid, repo: &R) -> Result<Identity<Oid>, IdentityError> {
-
        let mut history = repo.revwalk(head)?.collect::<Vec<_>>();
-

-
        // Retrieve root document.
-
        let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into();
-
        let root = Doc::<Verified>::load_at(root_oid, repo)?;
-
        let revision = history.len() as u32;
-

-
        // Every identity founder must have signed the root document.
-
        for founder in &root.doc.delegates {
-
            if !root.sigs.iter().any(|(k, _)| k == &**founder) {
-
                return Err(IdentityError::MissingRootSignatures);
-
            }
-
        }
-

-
        let mut current = root.blob;
-
        let mut trusted = root.doc;
-
        let mut signatures = root.sigs;
-

-
        // Traverse the history chronologically.
-
        for oid in history.into_iter().rev() {
-
            let oid = oid?;
-
            let untrusted = Doc::<Verified>::load_at(oid.into(), repo)?;
-

-
            // Check that enough delegates signed this next version.
-
            let quorum = untrusted
-
                .sigs
-
                .iter()
-
                .filter(|(key, _)| trusted.delegates.iter().any(|d| **d == **key))
-
                .count();
-
            if quorum < trusted.threshold {
-
                return Err(IdentityError::ThresholdNotReached(
-
                    quorum,
-
                    trusted.threshold,
-
                ));
-
            }
-

-
            current = untrusted.blob;
-
            trusted = untrusted.doc;
-
            signatures = untrusted.sigs;
-
        }
-

-
        Ok(Identity {
-
            root: root.blob,
-
            head,
-
            current,
-
            revision,
-
            doc: trusted,
-
            signatures: signatures.into_iter().collect(),
-
        })
-
    }
-
}
-

-
#[cfg(test)]
-
mod test {
-
    use qcheck_macros::quickcheck;
-
    use radicle_crypto::test::signer::MockSigner;
-
    use radicle_crypto::Signer as _;
-

-
    use crate::crypto::PublicKey;
-
    use crate::rad;
-
    use crate::storage::git::Storage;
-
    use crate::storage::{ReadStorage, WriteRepository};
-
    use crate::test::fixtures;
-

-
    use super::did::Did;
-
    use super::doc::PayloadId;
-
    use super::*;
-

-
    #[quickcheck]
-
    fn prop_json_eq_str(pk: PublicKey, proj: Id, did: Did) {
-
        let json = serde_json::to_string(&pk).unwrap();
-
        assert_eq!(format!("\"{pk}\""), json);
-

-
        let json = serde_json::to_string(&proj).unwrap();
-
        assert_eq!(format!("\"{}\"", proj.urn()), json);
-

-
        let json = serde_json::to_string(&did).unwrap();
-
        assert_eq!(format!("\"{did}\""), json);
-
    }
-

-
    #[test]
-
    fn test_valid_identity() {
-
        let tempdir = tempfile::tempdir().unwrap();
-
        let mut rng = fastrand::Rng::new();
-

-
        let alice = MockSigner::new(&mut rng);
-
        let bob = MockSigner::new(&mut rng);
-
        let eve = MockSigner::new(&mut rng);
-

-
        let storage = Storage::open(tempdir.path().join("storage")).unwrap();
-
        let (id, _, _, _) =
-
            fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap();
-

-
        // Bob and Eve fork the project from Alice.
-
        rad::fork_remote(id, alice.public_key(), &bob, &storage).unwrap();
-
        rad::fork_remote(id, alice.public_key(), &eve, &storage).unwrap();
-

-
        // TODO: In some cases we want to get the repo and the project, but don't
-
        // want to have to create a repository object twice. Perhaps there should
-
        // be a way of getting a project from a repo.
-
        let mut doc = storage.get(alice.public_key(), id).unwrap().unwrap();
-
        let prj = doc.project().unwrap();
-
        let repo = storage.repository(id).unwrap();
-

-
        // Make a change to the description and sign it.
-
        let desc = prj.description().to_owned() + "!";
-
        let prj = prj.update(None, desc, None).unwrap();
-
        doc.payload.insert(PayloadId::project(), prj.clone().into());
-
        doc.sign(&alice)
-
            .and_then(|(_, sig)| {
-
                doc.update(
-
                    alice.public_key(),
-
                    "Update description",
-
                    &[(alice.public_key(), sig)],
-
                    repo.raw(),
-
                )
-
            })
-
            .unwrap();
-

-
        // Add Bob as a delegate, and sign it.
-
        doc.delegate(bob.public_key());
-
        doc.threshold = 2;
-
        doc.sign(&alice)
-
            .and_then(|(_, sig)| {
-
                doc.update(
-
                    alice.public_key(),
-
                    "Add bob",
-
                    &[(alice.public_key(), sig)],
-
                    repo.raw(),
-
                )
-
            })
-
            .unwrap();
-

-
        // Add Eve as a delegate, and sign it.
-
        doc.delegate(eve.public_key());
-
        doc.sign(&alice)
-
            .and_then(|(_, alice_sig)| {
-
                doc.sign(&bob).and_then(|(_, bob_sig)| {
-
                    doc.update(
-
                        alice.public_key(),
-
                        "Add eve",
-
                        &[(alice.public_key(), alice_sig), (bob.public_key(), bob_sig)],
-
                        repo.raw(),
-
                    )
-
                })
-
            })
-
            .unwrap();
-

-
        // Update description again with signatures by Eve and Bob.
-
        let desc = prj.description().to_owned() + "?";
-
        let prj = prj.update(None, desc, None).unwrap();
-
        doc.payload.insert(PayloadId::project(), prj.into());
-
        let (current, head) = doc
-
            .sign(&bob)
-
            .and_then(|(_, bob_sig)| {
-
                doc.sign(&eve).and_then(|(blob_id, eve_sig)| {
-
                    doc.update(
-
                        alice.public_key(),
-
                        "Update description",
-
                        &[(bob.public_key(), bob_sig), (eve.public_key(), eve_sig)],
-
                        repo.raw(),
-
                    )
-
                    .map(|head| (blob_id, head))
-
                })
-
            })
-
            .unwrap();
-

-
        let identity: Identity<Id> = Identity::load(alice.public_key(), &repo)
-
            .unwrap()
-
            .verified(id)
-
            .unwrap();
-

-
        assert_eq!(identity.signatures.len(), 2);
-
        assert_eq!(identity.revision, 4);
-
        assert_eq!(identity.root, id);
-
        assert_eq!(identity.current, current);
-
        assert_eq!(identity.head, head);
-
        assert_eq!(identity.doc, doc);
-

-
        let doc = storage.get(alice.public_key(), id).unwrap().unwrap();
-
        assert_eq!(doc.project().unwrap().description(), "Acme's repository!?");
-
    }
-
}
+
pub use crate::cob::identity::{Error, Identity, IdentityMut};
modified radicle/src/identity/did.rs
@@ -103,6 +103,7 @@ impl Deref for Did {
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;

modified radicle/src/identity/doc.rs
@@ -1,8 +1,7 @@
mod id;

-
use std::collections::{BTreeMap, BTreeSet, HashMap};
+
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
-
use std::fmt::Write as _;
use std::marker::PhantomData;
use std::ops::{Deref, Not};
use std::path::Path;
@@ -10,18 +9,19 @@ use std::str::FromStr;

use nonempty::NonEmpty;
use once_cell::sync::Lazy;
+
use radicle_cob::type_name::{TypeName, TypeNameParse};
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::canonical::formatter::CanonicalFormatter;
+
use crate::cob::identity;
use crate::crypto;
use crate::crypto::{Signature, Unverified, Verified};
use crate::git;
use crate::identity::{project::Project, Did};
use crate::storage;
-
use crate::storage::git::trailers;
-
use crate::storage::{ReadRepository, RemoteId};
+
use crate::storage::{ReadRepository, RepositoryError};

pub use crypto::PublicKey;
pub use id::*;
@@ -35,24 +35,18 @@ pub const MAX_DELEGATES: usize = 255;

#[derive(Error, Debug)]
pub enum DocError {
-
    #[error("invalid commit: {0}")]
-
    Commit(&'static str),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
    #[error("invalid delegates: {0}")]
    Delegates(&'static str),
-
    #[error("invalid signature for {0}: {1}")]
-
    Signature(PublicKey, crypto::Error),
-
    #[error("invalid commit trailers: {0}")]
-
    Trailers(#[from] trailers::Error),
-
    #[error("invalid version `{0}`")]
-
    Version(u32),
    #[error("invalid threshold `{0}`: {1}")]
    Threshold(usize, &'static str),
    #[error("git: {0}")]
    GitExt(#[from] git::Error),
    #[error("git: {0}")]
    Git(#[from] git2::Error),
+
    #[error("missing identity document")]
+
    Missing,
}

impl DocError {
@@ -70,8 +64,7 @@ impl DocError {
/// Identifies an identity document payload type.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
-
// TODO: Restrict values.
-
pub struct PayloadId(String);
+
pub struct PayloadId(TypeName);

impl fmt::Display for PayloadId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -79,10 +72,22 @@ impl fmt::Display for PayloadId {
    }
}

+
impl FromStr for PayloadId {
+
    type Err = TypeNameParse;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        TypeName::from_str(s).map(Self)
+
    }
+
}
+

impl PayloadId {
    /// Project payload type.
    pub fn project() -> Self {
-
        Self(String::from("xyz.radicle.project"))
+
        Self(
+
            // SAFETY: We know this is valid.
+
            TypeName::from_str("xyz.radicle.project")
+
                .expect("PayloadId::project: type name is valid"),
+
        )
    }
}

@@ -101,6 +106,15 @@ pub struct Payload {
    value: serde_json::Value,
}

+
impl Payload {
+
    /// Get a mutable reference to the JSON map, or `None` if the payload is not a map.
+
    pub fn as_object_mut(
+
        &mut self,
+
    ) -> Option<&mut serde_json::value::Map<String, serde_json::Value>> {
+
        self.value.as_object_mut()
+
    }
+
}
+

impl From<serde_json::Value> for Payload {
    fn from(value: serde_json::Value) -> Self {
        Self { value }
@@ -124,8 +138,6 @@ pub struct DocAt {
    pub blob: Oid,
    /// The parsed document.
    pub doc: Doc<Verified>,
-
    /// The validated commit signatures.
-
    pub sigs: HashMap<PublicKey, Signature>,
}

impl Deref for DocAt {
@@ -136,6 +148,18 @@ impl Deref for DocAt {
    }
}

+
impl From<DocAt> for Doc<Verified> {
+
    fn from(value: DocAt) -> Self {
+
        value.doc
+
    }
+
}
+

+
impl AsRef<Doc<Verified>> for DocAt {
+
    fn as_ref(&self) -> &Doc<Verified> {
+
        &self.doc
+
    }
+
}
+

/// Repository visibility.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
@@ -209,21 +233,33 @@ impl<V> Doc<V> {
        }
    }

-
    pub fn canonical_head(repo: &storage::git::Repository) -> Result<Oid, DocError> {
-
        repo.backend
-
            .refname_to_id(storage::git::CANONICAL_IDENTITY.as_str())
-
            .map(Oid::from)
-
            .map_err(DocError::from)
+
    /// Validate signature using this document's delegates, against a given document blob.
+
    pub fn verify_signature(
+
        &self,
+
        key: &PublicKey,
+
        signature: &Signature,
+
        blob: Oid,
+
    ) -> Result<(), PublicKey> {
+
        if !self.is_delegate(key) {
+
            return Err(*key);
+
        }
+
        if key.verify(blob.as_bytes(), signature).is_err() {
+
            return Err(*key);
+
        }
+
        Ok(())
+
    }
+

+
    pub fn is_majority(&self, votes: usize) -> bool {
+
        votes >= self.majority()
    }

-
    pub fn head<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<Oid, DocError> {
-
        repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
-
            .map_err(DocError::from)
+
    pub fn majority(&self) -> usize {
+
        self.delegates.len() / 2 + 1
    }

    pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
-
        repo.blob_at(commit, Path::new(&*PATH))
-
            .map_err(DocError::from)
+
        let path = Path::new("embeds").join(*PATH);
+
        repo.blob_at(commit, path.as_path()).map_err(DocError::from)
    }

    pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool {
@@ -282,112 +318,58 @@ impl Doc<Verified> {
        Ok(proj)
    }

-
    pub fn sign<G: crypto::Signer>(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> {
-
        let (oid, _) = self.encode()?;
+
    pub fn sign<G: crypto::Signer>(
+
        &self,
+
        signer: &G,
+
    ) -> Result<(git::Oid, Vec<u8>, Signature), DocError> {
+
        let (oid, bytes) = self.encode()?;
        let sig = signer.sign(oid.as_bytes());

-
        Ok((oid, sig))
+
        Ok((oid, bytes, sig))
    }

-
    pub fn canonical(repo: &storage::git::Repository) -> Result<DocAt, DocError> {
-
        let oid = Self::canonical_head(repo)?;
-
        Self::load_at(oid, repo)
+
    pub fn signature_of<G: crypto::Signer>(&self, signer: &G) -> Result<Signature, DocError> {
+
        let (_, _, sig) = self.sign(signer)?;
+

+
        Ok(sig)
    }

-
    pub fn load_at<R: ReadRepository>(oid: Oid, repo: &R) -> Result<DocAt, DocError> {
-
        let blob = Self::blob_at(oid, repo)?;
-
        let doc = Doc::from_json(blob.content())?.verified()?;
-
        let commit = repo.commit(oid)?;
-
        let msg = commit
-
            .message_raw()
-
            .ok_or(DocError::Commit("commit message is not UTF-8"))?;
-
        let sigs = trailers::parse_signatures(msg)?;
+
    pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<DocAt, DocError> {
+
        let blob = Self::blob_at(commit, repo)?;
+
        let doc = Doc::from_blob(&blob)?;

-
        for (pk, sig) in &sigs {
-
            if let Err(err) = pk.verify(blob.id().as_bytes(), sig) {
-
                return Err(DocError::Signature(*pk, err));
-
            }
-
        }
        Ok(DocAt {
-
            commit: oid,
+
            commit,
            doc,
            blob: blob.id().into(),
-
            sigs,
        })
    }

-
    pub fn init(
-
        doc: &[u8],
-
        remote: &RemoteId,
-
        signatures: &[(&PublicKey, Signature)],
-
        repo: &git2::Repository,
-
    ) -> Result<git::Oid, DocError> {
-
        let tree = git::write_tree(*PATH, doc, repo)?;
-
        let oid = Doc::commit(remote, &tree, "Initialize Radicle\n", &[], signatures, repo)?;
-

-
        Ok(oid)
+
    pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
+
        Doc::from_json(blob.content())?.verified()
    }

-
    pub fn update(
+
    pub fn init<G: crypto::Signer>(
        &self,
-
        remote: &RemoteId,
-
        msg: &str,
-
        signatures: &[(&PublicKey, Signature)],
-
        repo: &git2::Repository,
-
    ) -> Result<git::Oid, DocError> {
-
        let (_, doc) = self.encode()?;
-
        let tree = git::write_tree(*PATH, doc.as_slice(), repo)?;
-
        let id_ref = git::refs::storage::id(remote);
-
        let head = repo.find_reference(&id_ref)?.peel_to_commit()?;
-
        let oid = Doc::commit(remote, &tree, msg, &[&head], signatures, repo)?;
-

-
        Ok(oid)
-
    }
-

-
    fn commit(
-
        remote: &RemoteId,
-
        tree: &git2::Tree,
-
        msg: &str,
-
        parents: &[&git2::Commit],
-
        signatures: &[(&PublicKey, Signature)],
-
        repo: &git2::Repository,
-
    ) -> Result<git::Oid, DocError> {
-
        let sig = repo
-
            .signature()
-
            .or_else(|_| git2::Signature::now("radicle", remote.to_string().as_str()))?;
-

-
        #[cfg(debug_assertions)]
-
        let sig = if let Ok(s) = std::env::var("RAD_COMMIT_TIME") {
-
            // SAFETY: Only used in test code.
-
            #[allow(clippy::unwrap_used)]
-
            let timestamp = s.trim().parse::<i64>().unwrap();
-
            let time = git2::Time::new(timestamp, 0);
-
            git2::Signature::new("radicle", remote.to_string().as_str(), &time)?
-
        } else {
-
            sig
-
        };
-

-
        let mut msg = format!("{}\n\n", msg.trim());
-
        for (key, sig) in signatures {
-
            writeln!(&mut msg, "{}: {key} {sig}", trailers::SIGNATURE_TRAILER)
-
                .expect("in-memory writes don't fail");
-
        }
-

-
        let id_ref = git::refs::storage::id(remote);
-
        let oid = repo.commit(Some(&id_ref), &sig, &sig, &msg, tree, parents)?;
+
        repo: &storage::git::Repository,
+
        signer: &G,
+
    ) -> Result<git::Oid, RepositoryError> {
+
        let cob = identity::Identity::initialize(self, repo, signer)?;
+
        let id_ref = git::refs::storage::id(signer.public_key());
+
        let cob_ref = git::refs::storage::cob(
+
            signer.public_key(),
+
            &crate::cob::identity::TYPENAME,
+
            &cob.id,
+
        );
+
        // Set `.../refs/rad/id` -> `.../refs/cobs/xyz.radicle.id/<id>`
+
        repo.backend.reference_symbolic(
+
            id_ref.as_str(),
+
            cob_ref.as_str(),
+
            false,
+
            "Create `rad/id` reference to point to new identity COB",
+
        )?;

-
        Ok(oid.into())
-
    }
-

-
    #[cfg(any(test, feature = "test"))]
-
    pub(crate) fn unverified(self) -> Doc<Unverified> {
-
        Doc {
-
            payload: self.payload,
-
            delegates: self.delegates,
-
            threshold: self.threshold,
-
            visibility: self.visibility,
-
            verified: PhantomData,
-
        }
+
        Ok(*cob.id)
    }
}

@@ -446,22 +428,10 @@ impl Doc<Unverified> {
            verified: PhantomData,
        })
    }
-

-
    pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> {
-
        let blob = Self::blob_at(commit, repo)?;
-
        let doc = Doc::from_json(blob.content())?;
-

-
        Ok((doc, blob.id().into()))
-
    }
-

-
    pub fn load<R: ReadRepository>(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> {
-
        let oid = Self::head(remote, repo)?;
-

-
        Self::load_at(oid, repo)
-
    }
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test {
    use radicle_crypto::test::signer::MockSigner;
    use radicle_crypto::Signer as _;
@@ -469,7 +439,7 @@ mod test {
    use crate::rad;
    use crate::storage::git::transport;
    use crate::storage::git::Storage;
-
    use crate::storage::{ReadStorage as _, WriteStorage as _};
+
    use crate::storage::{ReadStorage as _, RemoteId, WriteStorage as _};
    use crate::test::arbitrary;
    use crate::test::fixtures;

@@ -516,10 +486,10 @@ mod test {
        let repo = storage.create(proj).unwrap();
        let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();

-
        let err = Doc::<Unverified>::head(&remote, &repo).unwrap_err();
-
        assert!(err.is_not_found());
+
        let err = repo.identity_head_of(&remote).unwrap_err();
+
        matches!(err, git::ext::Error::NotFound(_));

-
        let err = Doc::<Unverified>::load_at(oid.into(), &repo).unwrap_err();
+
        let err = Doc::<Verified>::load_at(oid.into(), &repo).unwrap_err();
        assert!(err.is_not_found());
    }

@@ -544,7 +514,7 @@ mod test {
        .unwrap();
        let repo = storage.repository(rid).unwrap();

-
        assert_eq!(doc, Doc::canonical(&repo).unwrap().doc);
+
        assert_eq!(doc, repo.identity_doc().unwrap().doc);
    }

    #[quickcheck]
modified radicle/src/identity/doc/id.rs
@@ -136,6 +136,7 @@ impl From<&Id> for Component<'_> {
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;
    use qcheck_macros::quickcheck;
modified radicle/src/lib.rs
@@ -23,6 +23,7 @@ pub mod storage;
pub mod test;
pub mod version;

+
pub use cob::{issue, patch};
pub use node::Node;
pub use profile::Profile;
pub use storage::git::Storage;
modified radicle/src/rad.rs
@@ -9,12 +9,13 @@ use thiserror::Error;
use crate::cob::ObjectId;
use crate::crypto::{Signer, Verified};
use crate::git;
+
use crate::identity::doc;
use crate::identity::doc::{DocError, Id, Visibility};
use crate::identity::project::Project;
-
use crate::identity::{doc, IdentityError};
use crate::storage::git::transport;
use crate::storage::git::Repository;
use crate::storage::refs::SignedRefs;
+
use crate::storage::RepositoryError;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, SignRepository as _};
use crate::storage::{WriteRepository, WriteStorage};
use crate::{identity, storage};
@@ -30,8 +31,8 @@ pub static PATCHES_REFNAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("r
pub enum InitError {
    #[error("doc: {0}")]
    Doc(#[from] DocError),
-
    #[error("project: {0}")]
-
    Identity(#[from] IdentityError),
+
    #[error("repository: {0}")]
+
    Repository(#[from] RepositoryError),
    #[error("project payload: {0}")]
    ProjectPayload(String),
    #[error("git: {0}")]
@@ -40,12 +41,6 @@ pub enum InitError {
    Io(#[from] io::Error),
    #[error("storage: {0}")]
    Storage(#[from] storage::Error),
-
    #[error("cannot initialize project inside a bare repository")]
-
    BareRepo,
-
    #[error("cannot initialize project from detached head state")]
-
    DetachedHead,
-
    #[error("HEAD reference is not valid UTF-8")]
-
    InvalidHead,
}

/// Initialize a new radicle project from a git repository.
@@ -75,7 +70,7 @@ pub fn init<G: Signer, S: WriteStorage>(
        )
    })?;
    let doc = identity::Doc::initial(proj, delegate, visibility).verified()?;
-
    let (project, _) = Repository::init(&doc, pk, storage, signer)?;
+
    let (project, _) = Repository::init(&doc, storage, signer)?;
    let url = git::Url::from(project.id);

    git::configure_repository(repo)?;
@@ -88,33 +83,26 @@ pub fn init<G: Signer, S: WriteStorage>(
            &git::fmt::lit::refs_heads(&default_branch).into(),
        )],
    )?;
+

    let signed = project.sign_refs(signer)?;
-
    let _head = project.set_head()?;
    let _head = project.set_identity_head()?;
+
    let _head = project.set_head()?;

    Ok((project.id, doc, signed))
}

#[derive(Error, Debug)]
pub enum ForkError {
-
    #[error("ref string: {0}")]
-
    RefString(#[from] git::fmt::Error),
    #[error("git: {0}")]
    Git(#[from] git2::Error),
-
    #[error("git: {0}")]
-
    GitExt(#[from] git::Error),
    #[error("storage: {0}")]
    Storage(#[from] storage::Error),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
    #[error("project `{0}` was not found in storage")]
    NotFound(Id),
-
    #[error("project identity error: {0}")]
-
    InvalidIdentity(#[from] IdentityError),
-
    #[error("project identity document error: {0}")]
-
    Doc(#[from] DocError),
-
    #[error("git: invalid reference")]
-
    InvalidReference,
+
    #[error("repository: {0}")]
+
    Repository(#[from] RepositoryError),
}

/// Create a local tree for an existing project, from an existing remote.
@@ -129,14 +117,11 @@ pub fn fork_remote<G: Signer, S: storage::WriteStorage>(
    // Creates or copies the following references:
    //
    // refs/namespaces/<pk>/refs/heads/master
-
    // refs/namespaces/<pk>/refs/rad/id
    // refs/namespaces/<pk>/refs/rad/sigrefs
    // refs/namespaces/<pk>/refs/tags/*

    let me = signer.public_key();
-
    let doc = storage
-
        .get(remote, proj)?
-
        .ok_or(ForkError::NotFound(proj))?;
+
    let doc = storage.get(proj)?.ok_or(ForkError::NotFound(proj))?;
    let project = doc.project()?;
    let repository = storage.repository_mut(proj)?;

@@ -151,15 +136,6 @@ pub fn fork_remote<G: Signer, S: storage::WriteStorage>(
        false,
        &format!("creating default branch for {me}"),
    )?;
-

-
    let remote_id = raw.refname_to_id(&git::refs::storage::id(remote))?;
-
    raw.reference(
-
        &git::refs::storage::id(me),
-
        remote_id,
-
        false,
-
        &format!("creating identity branch for {me}"),
-
    )?;
-

    repository.sign_refs(signer)?;

    Ok(())
@@ -172,7 +148,6 @@ pub fn fork<G: Signer, S: storage::WriteStorage>(
) -> Result<(), ForkError> {
    let me = signer.public_key();
    let repository = storage.repository_mut(rid)?;
-
    let (canonical_id, _) = repository.identity_doc()?;
    let (canonical_branch, canonical_head) = repository.head()?;
    let raw = repository.raw();

@@ -182,12 +157,6 @@ pub fn fork<G: Signer, S: storage::WriteStorage>(
        true,
        &format!("creating default branch for {me}"),
    )?;
-
    raw.reference(
-
        &git::refs::storage::id(me),
-
        canonical_id.into(),
-
        true,
-
        &format!("creating identity branch for {me}"),
-
    )?;
    repository.sign_refs(signer)?;

    Ok(())
@@ -199,14 +168,12 @@ pub enum CheckoutError {
    Fetch(#[source] git2::Error),
    #[error("git: {0}")]
    Git(#[from] git2::Error),
-
    #[error("storage: {0}")]
-
    Storage(#[from] storage::Error),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
    #[error("project `{0}` was not found in storage")]
    NotFound(Id),
-
    #[error("project error: {0}")]
-
    Identity(#[from] IdentityError),
+
    #[error("repository: {0}")]
+
    Repository(#[from] RepositoryError),
}

/// Checkout a project from storage as a working copy.
@@ -219,9 +186,7 @@ pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
) -> Result<git2::Repository, CheckoutError> {
    // TODO: Decide on whether we can use `clone_local`
    // TODO: Look into sharing object databases.
-
    let doc = storage
-
        .get(remote, proj)?
-
        .ok_or(CheckoutError::NotFound(proj))?;
+
    let doc = storage.get(proj)?.ok_or(CheckoutError::NotFound(proj))?;
    let project = doc.project()?;

    let mut opts = git2::RepositoryInitOptions::new();
@@ -364,6 +329,7 @@ pub fn setup_patch_upstream<'a>(
mod tests {
    use std::collections::HashMap;

+
    use pretty_assertions::assert_eq;
    use radicle_crypto::test::signer::MockSigner;

    use crate::git::{name::component, qualified};
@@ -396,7 +362,7 @@ mod tests {
        )
        .unwrap();

-
        let doc = storage.get(&public_key, proj).unwrap().unwrap();
+
        let doc = storage.get(proj).unwrap().unwrap();
        let project = doc.project().unwrap();
        let remotes: HashMap<_, _> = storage
            .repository(proj)
modified radicle/src/storage.rs
@@ -14,13 +14,13 @@ use crypto::{PublicKey, Signer, Unverified, Verified};
pub use git::VerifyError;
pub use radicle_git_ext::Oid;

+
use crate::cob;
use crate::collections::RandomMap;
use crate::git::ext as git_ext;
use crate::git::{refspec::Refspec, PatternString, Qualified, RefError, RefString};
-
use crate::identity;
-
use crate::identity::doc::DocError;
-
use crate::identity::Did;
-
use crate::identity::{Id, Identity, IdentityError};
+
use crate::identity::{Did, PayloadError};
+
use crate::identity::{Doc, DocAt, DocError};
+
use crate::identity::{Id, Identity};
use crate::storage::git::NAMESPACES_GLOB;
use crate::storage::refs::Refs;

@@ -68,6 +68,27 @@ impl FromIterator<PublicKey> for Namespaces {
    }
}

+
/// Repository error.
+
#[derive(Error, Debug)]
+
pub enum RepositoryError {
+
    #[error(transparent)]
+
    Storage(#[from] Error),
+
    #[error(transparent)]
+
    Store(#[from] cob::store::Error),
+
    #[error(transparent)]
+
    Doc(#[from] DocError),
+
    #[error(transparent)]
+
    Payload(#[from] PayloadError),
+
    #[error(transparent)]
+
    Git(#[from] git::raw::Error),
+
    #[error(transparent)]
+
    GitExt(#[from] git_ext::Error),
+
    #[error(transparent)]
+
    Quorum(#[from] git::QuorumError),
+
    #[error(transparent)]
+
    Refs(#[from] refs::Error),
+
}
+

/// Storage error.
#[derive(Error, Debug)]
pub enum Error {
@@ -113,9 +134,10 @@ pub enum FetchError {
    Verify(#[from] git::VerifyError),
    #[error(transparent)]
    Storage(#[from] Error),
-
    // TODO: This should wrap a more specific error.
    #[error("repository head: {0}")]
-
    SetHead(#[from] IdentityError),
+
    SetHead(#[from] DocError),
+
    #[error("repository: {0}")]
+
    Repository(#[from] RepositoryError),
}

pub type RemoteId = PublicKey;
@@ -294,19 +316,21 @@ pub trait ReadStorage {
    fn path(&self) -> &Path;
    /// Get a repository's path.
    fn path_of(&self, rid: &Id) -> PathBuf;
-
    /// Get an identity document of a repository under a given remote.
-
    fn get(
-
        &self,
-
        remote: &RemoteId,
-
        rid: Id,
-
    ) -> Result<Option<identity::Doc<Verified>>, IdentityError>;
    /// Check whether storage contains a repository.
-
    fn contains(&self, rid: &Id) -> Result<bool, IdentityError>;
+
    fn contains(&self, rid: &Id) -> Result<bool, RepositoryError>;
    /// Get the inventory of repositories hosted under this storage.
    /// This function should typically only return public repositories.
    fn inventory(&self) -> Result<Inventory, Error>;
    /// Open or create a read-only repository.
    fn repository(&self, rid: Id) -> Result<Self::Repository, Error>;
+
    /// Get a repository's identity if it exists.
+
    fn get(&self, rid: Id) -> Result<Option<Doc<Verified>>, RepositoryError> {
+
        match self.repository(rid) {
+
            Ok(repo) => Ok(Some(repo.identity_doc()?.into())),
+
            Err(e) if e.is_not_found() => Ok(None),
+
            Err(e) => Err(e.into()),
+
        }
+
    }
}

/// Allows access to individual storage repositories.
@@ -331,8 +355,7 @@ pub trait ReadRepository: Sized {
    fn path(&self) -> &Path;

    /// Get a blob in this repository at the given commit and path.
-
    fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path)
-
        -> Result<git2::Blob<'a>, git_ext::Error>;
+
    fn blob_at<P: AsRef<Path>>(&self, commit: Oid, path: P) -> Result<git2::Blob, git_ext::Error>;

    /// Get a blob in this repository, given its id.
    fn blob(&self, oid: Oid) -> Result<git2::Blob, git_ext::Error>;
@@ -357,32 +380,50 @@ pub trait ReadRepository: Sized {
    /// head using [`ReadRepository::canonical_head`].
    ///
    /// Returns the [`Oid`] as well as the qualified reference name.
-
    fn head(&self) -> Result<(Qualified, Oid), IdentityError>;
+
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError>;

    /// Compute the canonical head of this repository.
    ///
    /// Ignores any existing `HEAD` reference.
    ///
    /// Returns the [`Oid`] as well as the qualified reference name.
-
    fn canonical_head(&self) -> Result<(Qualified, Oid), IdentityError>;
+
    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError>;

    /// Get the head of the `rad/id` reference in this repository.
    ///
    /// Returns the reference pointed to by `rad/id` if it is set. Otherwise, computes the canonical
    /// `rad/id` using [`ReadRepository::canonical_identity_head`].
-
    fn identity_head(&self) -> Result<Oid, IdentityError>;
+
    fn identity_head(&self) -> Result<Oid, RepositoryError>;

-
    /// Load the canonical identity.
-
    fn identity(&self) -> Result<Identity<Oid>, IdentityError> {
-
        let head = self.identity_head()?;
+
    /// Get the identity head of a specific remote.
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, git::ext::Error>;

-
        Identity::load_at(head, self)
+
    /// Get the root commit of the canonical identity branch.
+
    fn identity_root(&self) -> Result<Oid, RepositoryError>;
+

+
    /// Get the root commit of the identity branch of a sepcific remote.
+
    fn identity_root_of(&self, remote: &RemoteId) -> Result<Oid, RepositoryError>;
+

+
    /// Load the identity history.
+
    fn identity(&self) -> Result<Identity, RepositoryError>
+
    where
+
        Self: cob::Store,
+
    {
+
        Identity::load(self)
    }

    /// Compute the canonical `rad/id` of this repository.
    ///
    /// Ignores any existing `rad/id` reference.
-
    fn canonical_identity_head(&self) -> Result<Oid, IdentityError>;
+
    fn canonical_identity_head(&self) -> Result<Oid, RepositoryError>;
+

+
    /// Compute the canonical identity document.
+
    fn canonical_identity_doc(&self) -> Result<DocAt, RepositoryError> {
+
        let head = self.canonical_identity_head()?;
+
        let doc = self.identity_doc_at(head)?;
+

+
        Ok(doc)
+
    }

    /// Get the `reference` for the given `remote`.
    ///
@@ -431,23 +472,22 @@ pub trait ReadRepository: Sized {
    fn remotes(&self) -> Result<Remotes<Verified>, refs::Error>;

    /// Get repository delegates.
-
    fn delegates(&self) -> Result<NonEmpty<Did>, IdentityError> {
-
        let (_, doc) = self.identity_doc()?;
-
        let doc = doc.verified()?;
+
    fn delegates(&self) -> Result<NonEmpty<Did>, RepositoryError> {
+
        let doc: Doc<_> = self.identity_doc()?.into();

        Ok(doc.delegates)
    }

    /// Get the repository's identity document.
-
    fn identity_doc(&self) -> Result<(Oid, identity::Doc<Unverified>), IdentityError> {
+
    fn identity_doc(&self) -> Result<DocAt, RepositoryError> {
        let head = self.identity_head()?;
        let doc = self.identity_doc_at(head)?;

-
        Ok((head, doc))
+
        Ok(doc)
    }

    /// Get the repository's identity document at a specific commit.
-
    fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError>;
+
    fn identity_doc_at(&self, head: Oid) -> Result<DocAt, DocError>;

    /// Get the merge base of two commits.
    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, git::ext::Error>;
@@ -457,9 +497,16 @@ pub trait ReadRepository: Sized {
pub trait WriteRepository: ReadRepository + SignRepository {
    /// Set the repository head to the canonical branch.
    /// This computes the head based on the delegate set.
-
    fn set_head(&self) -> Result<Oid, IdentityError>;
+
    fn set_head(&self) -> Result<Oid, RepositoryError>;
    /// Set the repository 'rad/id' to the canonical commit, agreed by quorum.
-
    fn set_identity_head(&self) -> Result<Oid, IdentityError>;
+
    fn set_identity_head(&self) -> Result<Oid, RepositoryError> {
+
        let head = self.canonical_identity_head()?;
+
        self.set_identity_head_to(head)?;
+

+
        Ok(head)
+
    }
+
    /// Set the repository 'rad/id' to the given commit.
+
    fn set_identity_head_to(&self, commit: Oid) -> Result<(), RepositoryError>;
    /// Get the underlying git repository.
    fn raw(&self) -> &git2::Repository;
}
@@ -485,7 +532,7 @@ where
        self.deref().path_of(rid)
    }

-
    fn contains(&self, rid: &Id) -> Result<bool, IdentityError> {
+
    fn contains(&self, rid: &Id) -> Result<bool, RepositoryError> {
        self.deref().contains(rid)
    }

@@ -493,12 +540,8 @@ where
        self.deref().inventory()
    }

-
    fn get(
-
        &self,
-
        remote: &RemoteId,
-
        proj: Id,
-
    ) -> Result<Option<identity::Doc<Verified>>, IdentityError> {
-
        self.deref().get(remote, proj)
+
    fn get(&self, rid: Id) -> Result<Option<Doc<Verified>>, RepositoryError> {
+
        self.deref().get(rid)
    }

    fn repository(&self, rid: Id) -> Result<Self::Repository, Error> {
modified radicle/src/storage/git.rs
@@ -1,3 +1,4 @@
+
#![warn(clippy::unwrap_used)]
pub mod cob;
pub mod transport;

@@ -5,19 +6,19 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::{fs, io};

-
use crypto::{Signer, Unverified, Verified};
+
use crypto::{Signer, Verified};
use once_cell::sync::Lazy;

+
use crate::crypto::Unverified;
use crate::git;
-
use crate::identity;
use crate::identity::doc::DocError;
-
use crate::identity::{Doc, Id};
-
use crate::identity::{Identity, IdentityError, Project};
+
use crate::identity::{doc::DocAt, Doc, Id};
+
use crate::identity::{Identity, Project};
use crate::storage::refs;
use crate::storage::refs::{Refs, SignedRefs};
use crate::storage::{
-
    Inventory, ReadRepository, ReadStorage, Remote, Remotes, SignRepository, WriteRepository,
-
    WriteStorage,
+
    Inventory, ReadRepository, ReadStorage, Remote, Remotes, RepositoryError, SignRepository,
+
    WriteRepository, WriteStorage,
};

pub use crate::git::*;
@@ -66,10 +67,8 @@ impl<'a> TryFrom<git2::Reference<'a>> for Ref {
            Err(RefError::MissingNamespace(refname)) => (None, refname),
            Err(err) => return Err(err),
        };
-
        let Some(oid) = r.target() else {
-
            // Ignore symbolic refs, eg. `HEAD`.
-
            return Err(RefError::Symbolic(name));
-
        };
+
        let oid = r.resolve()?.target().ok_or(RefError::NoTarget)?;
+

        Ok(Self {
            namespace,
            name,
@@ -94,7 +93,7 @@ impl ReadStorage for Storage {
        paths::repository(&self, rid)
    }

-
    fn contains(&self, rid: &Id) -> Result<bool, IdentityError> {
+
    fn contains(&self, rid: &Id) -> Result<bool, RepositoryError> {
        if paths::repository(&self, rid).exists() {
            let _ = self.repository(*rid)?.head()?;
            return Ok(true);
@@ -102,19 +101,6 @@ impl ReadStorage for Storage {
        Ok(false)
    }

-
    fn get(&self, remote: &RemoteId, proj: Id) -> Result<Option<Doc<Verified>>, IdentityError> {
-
        let repo = match self.repository(proj) {
-
            Ok(doc) => doc,
-
            Err(e) if e.is_not_found() => return Ok(None),
-
            Err(e) => return Err(e.into()),
-
        };
-
        match repo.identity_doc_of(remote) {
-
            Ok(doc) => Ok(Some(doc)),
-
            Err(e) if e.is_not_found() => Ok(None),
-
            Err(e) => Err(e),
-
        }
-
    }
-

    fn inventory(&self) -> Result<Inventory, Error> {
        let repos = self.repositories()?;

@@ -160,7 +146,7 @@ impl Storage {
        self.path.as_path()
    }

-
    pub fn repositories(&self) -> Result<Vec<RepositoryInfo<Unverified>>, Error> {
+
    pub fn repositories(&self) -> Result<Vec<RepositoryInfo<Verified>>, Error> {
        let mut repos = Vec::new();

        for result in fs::read_dir(&self.path)? {
@@ -185,7 +171,7 @@ impl Storage {
                }
            };
            let doc = match repo.identity_doc() {
-
                Ok((_, doc)) => doc,
+
                Ok(doc) => doc.into(),
                Err(e) => {
                    log::warn!(target: "storage", "Repository {rid} is invalid: looking up doc: {e}");
                    continue;
@@ -213,7 +199,7 @@ impl Storage {
            for r in repo.raw().references()? {
                let r = r?;
                let name = r.name().ok_or(Error::InvalidRef)?;
-
                let oid = r.target().ok_or(Error::InvalidRef)?;
+
                let oid = r.resolve()?.target().ok_or(Error::InvalidRef)?;

                println!("{} {oid} {name}", rid.urn());
            }
@@ -222,25 +208,22 @@ impl Storage {
    }
}

+
/// Git implementation of [`WriteRepository`] using the `git2` crate.
pub struct Repository {
+
    /// The repository identifier (RID).
    pub id: Id,
+
    /// The backing Git repository.
    pub backend: git2::Repository,
}

#[derive(Debug, Error)]
pub enum VerifyError {
-
    #[error("invalid remote `{0}`")]
-
    InvalidRemote(RemoteId),
    #[error("invalid target `{2}` for reference `{1}` of remote `{0}`")]
    InvalidRefTarget(RemoteId, RefString, git2::Oid),
-
    #[error("invalid identity: {0}")]
-
    InvalidIdentity(#[from] IdentityError),
    #[error("refs error: {0}")]
    Refs(#[from] refs::Error),
    #[error("missing reference `{1}` in remote `{0}`")]
    MissingRef(RemoteId, git::RefString),
-
    #[error("git: {0}")]
-
    Git(#[from] git2::Error),
    #[error(transparent)]
    Storage(#[from] Error),
}
@@ -274,28 +257,22 @@ impl Repository {
    /// Create the repository's identity branch.
    pub fn init<G: Signer, S: WriteStorage>(
        doc: &Doc<Verified>,
-
        remote: &RemoteId,
        storage: S,
        signer: &G,
-
    ) -> Result<(Self, git::Oid), Error> {
-
        let (doc_oid, doc) = doc.encode()?;
+
    ) -> Result<(Self, git::Oid), RepositoryError> {
+
        let (doc_oid, _) = doc.encode()?;
        let id = Id::from(doc_oid);
        let repo = Self::create(paths::repository(&storage, &id), id)?;
-
        let oid = Doc::init(
-
            doc.as_slice(),
-
            remote,
-
            &[(signer.public_key(), signer.sign(doc_oid.as_bytes()))],
-
            repo.raw(),
-
        )?;
+
        let commit = doc.init(&repo, signer)?;

-
        Ok((repo, oid))
+
        Ok((repo, commit))
    }

    pub fn inspect(&self) -> Result<(), Error> {
        for r in self.backend.references()? {
            let r = r?;
            let name = r.name().ok_or(Error::InvalidRef)?;
-
            let oid = r.target().ok_or(Error::InvalidRef)?;
+
            let oid = r.resolve()?.target().ok_or(Error::InvalidRef)?;

            println!("{oid} {name}");
        }
@@ -313,7 +290,6 @@ impl Repository {
                let r = reference?;

                match Ref::try_from(r) {
-
                    Err(RefError::Symbolic(_)) => Ok(None),
                    Err(err) => Err(err.into()),
                    Ok(r) => Ok(Some(r)),
                }
@@ -323,24 +299,18 @@ impl Repository {
        Ok(refs)
    }

-
    pub fn identity_of(&self, remote: &RemoteId) -> Result<Identity<Oid>, IdentityError> {
-
        Identity::load(remote, self)
-
    }
-

    /// Get the canonical project information.
-
    pub fn project(&self) -> Result<Project, IdentityError> {
+
    pub fn project(&self) -> Result<Project, RepositoryError> {
        let head = self.identity_head()?;
        let doc = self.identity_doc_at(head)?;
-
        let proj = doc.verified()?.project()?;
+
        let proj = doc.project()?;

        Ok(proj)
    }

-
    pub fn identity_doc_of(&self, remote: &RemoteId) -> Result<Doc<Verified>, IdentityError> {
-
        let (doc, _) = identity::Doc::load(remote, self)?;
-
        let verified = doc.verified()?;
-

-
        Ok(verified)
+
    pub fn identity_doc_of(&self, remote: &RemoteId) -> Result<Doc<Verified>, DocError> {
+
        let oid = self.identity_head_of(remote)?;
+
        Doc::load_at(oid, self).map(|d| d.into())
    }

    pub fn remote_ids(
@@ -392,12 +362,18 @@ impl ReadRepository for Repository {
        self.backend.path()
    }

-
    fn blob_at<'a>(&'a self, commit: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
-
        git::ext::Blob::At {
-
            object: commit.into(),
-
            path,
-
        }
-
        .get(&self.backend)
+
    fn blob_at<P: AsRef<Path>>(&self, commit: Oid, path: P) -> Result<git2::Blob, git::Error> {
+
        let commit = self.backend.find_commit(*commit)?;
+
        let tree = commit.tree()?;
+
        let entry = tree.get_path(path.as_ref())?;
+
        let obj = entry.to_object(&self.backend)?;
+
        let blob = obj.into_blob().map_err(|_| {
+
            git::Error::NotFound(git::NotFound::NoSuchBlob(
+
                path.as_ref().display().to_string(),
+
            ))
+
        })?;
+

+
        Ok(blob)
    }

    fn blob(&self, oid: Oid) -> Result<git2::Blob, git::Error> {
@@ -429,8 +405,8 @@ impl ReadRepository for Repository {
        if let Some((name, _)) = signed.into_iter().next() {
            return Err(VerifyError::MissingRef(remote.id, name));
        }
-
        // Finally, verify the identity history of remote.
-
        self.identity_of(&remote.id)?.verified(self.id)?;
+
        // Nb. As it stands, it doesn't make sense to verify a single remote's identity branch,
+
        // since it is a COB.

        Ok(unsigned)
    }
@@ -489,7 +465,7 @@ impl ReadRepository for Repository {
            let e = e?;
            let name = e.name().ok_or(Error::InvalidRef)?;
            let (_, refname) = git::parse_ref::<RemoteId>(name)?;
-
            let oid = e.target().ok_or(Error::InvalidRef)?;
+
            let oid = e.resolve()?.target().ok_or(Error::InvalidRef)?;
            let (_, category, _, _) = refname.non_empty_components();

            if [
@@ -536,11 +512,11 @@ impl ReadRepository for Repository {
        Ok(Remotes::from_iter(remotes))
    }

-
    fn identity_doc_at(&self, head: Oid) -> Result<identity::Doc<Unverified>, DocError> {
-
        Doc::<Unverified>::load_at(head, self).map(|(doc, _)| doc)
+
    fn identity_doc_at(&self, head: Oid) -> Result<DocAt, DocError> {
+
        Doc::<Verified>::load_at(head, self)
    }

-
    fn head(&self) -> Result<(Qualified, Oid), IdentityError> {
+
    fn head(&self) -> Result<(Qualified, Oid), RepositoryError> {
        // If `HEAD` is already set locally, just return that.
        if let Ok(head) = self.backend.head() {
            if let Ok((name, oid)) = git::refs::qualified_from(&head) {
@@ -550,9 +526,8 @@ impl ReadRepository for Repository {
        self.canonical_head()
    }

-
    fn canonical_head(&self) -> Result<(Qualified, Oid), IdentityError> {
-
        let (_, doc) = self.identity_doc()?;
-
        let doc = doc.verified()?;
+
    fn canonical_head(&self) -> Result<(Qualified, Oid), RepositoryError> {
+
        let doc = self.identity_doc()?;
        let project = doc.project()?;
        let branch_ref = git::refs::branch(project.default_branch());
        let raw = self.raw();
@@ -568,60 +543,63 @@ impl ReadRepository for Repository {
        Ok((branch_ref, quorum))
    }

-
    fn identity_head(&self) -> Result<Oid, IdentityError> {
-
        match Doc::<Verified>::canonical_head(self) {
+
    fn identity_head(&self) -> Result<Oid, RepositoryError> {
+
        let result = self
+
            .backend
+
            .refname_to_id(CANONICAL_IDENTITY.as_str())
+
            .map(Oid::from);
+

+
        match result {
            Ok(oid) => Ok(oid),
-
            Err(err) if err.is_not_found() => self.canonical_identity_head(),
+
            Err(err) if git::ext::is_not_found_err(&err) => self.canonical_identity_head(),
            Err(err) => Err(err.into()),
        }
    }

-
    fn canonical_identity_head(&self) -> Result<Oid, IdentityError> {
-
        let mut heads = Vec::new();
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, git::ext::Error> {
+
        self.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
+
    }

+
    fn identity_root(&self) -> Result<Oid, RepositoryError> {
+
        let oid = self.backend.refname_to_id(CANONICAL_IDENTITY.as_str())?;
+
        let walk = self.revwalk(oid.into())?.collect::<Vec<_>>();
+
        let root = walk
+
            .into_iter()
+
            .last()
+
            .ok_or(RepositoryError::Doc(DocError::Missing))??;
+

+
        Ok(root.into())
+
    }
+

+
    fn identity_root_of(&self, remote: &RemoteId) -> Result<Oid, RepositoryError> {
+
        let oid = self.identity_head_of(remote)?;
+
        let walk = self.revwalk(oid)?.collect::<Vec<_>>();
+
        let root = walk
+
            .into_iter()
+
            .last()
+
            .ok_or(RepositoryError::Doc(DocError::Missing))??;
+

+
        Ok(root.into())
+
    }
+

+
    fn canonical_identity_head(&self) -> Result<Oid, RepositoryError> {
        for remote in self.remote_ids()? {
            let remote = remote?;
-
            let oid = Doc::<Unverified>::head(&remote, self)?;
+
            // Nb. A remote may not have an identity document if the user has not contributed
+
            // any changes to the identity COB.
+
            let Ok(root) = self.identity_root_of(&remote) else {
+
                continue;
+
            };
+
            let blob = Doc::<Unverified>::blob_at(root, self)?;

-
            heads.push(oid.into());
-
        }
-
        // Keep track of the longest identity branch.
-
        let mut longest = heads.pop().ok_or(IdentityError::MissingBranch)?;
-

-
        for head in &heads {
-
            let base = self.raw().merge_base(*head, longest)?;
-

-
            if base == longest {
-
                // `head` is a successor of `longest`. Update `longest`.
-
                //
-
                //   o head
-
                //   |
-
                //   o longest (base)
-
                //   |
-
                //
-
                longest = *head;
-
            } else if base == *head || *head == longest {
-
                // `head` is an ancestor of `longest`, or equal to it. Do nothing.
-
                //
-
                //   o longest             o longest, head (base)
-
                //   |                     |
-
                //   o head (base)   OR    o
-
                //   |                     |
-
                //
-
            } else {
-
                // The merge base between `head` and `longest` (`base`)
-
                // is neither `head` nor `longest`. Therefore, the branches have
-
                // diverged.
-
                //
-
                //    longest   head
-
                //           \ /
-
                //            o (base)
-
                //            |
-
                //
-
                return Err(IdentityError::BranchesDiverge);
+
            // We've got an identity that goes back to the correct root.
+
            if blob.id() == **self.id {
+
                let identity = Identity::get(&root.into(), self)?;
+

+
                return Ok(identity.head());
            }
        }
-
        Ok(longest.into())
+
        Err(DocError::Missing.into())
    }

    fn merge_base(&self, left: &Oid, right: &Oid) -> Result<Oid, ext::Error> {
@@ -633,7 +611,7 @@ impl ReadRepository for Repository {
}

impl WriteRepository for Repository {
-
    fn set_head(&self) -> Result<Oid, IdentityError> {
+
    fn set_head(&self) -> Result<Oid, RepositoryError> {
        let head_ref = refname!("HEAD");
        let (branch_ref, head) = self.canonical_head()?;

@@ -648,18 +626,15 @@ impl WriteRepository for Repository {
        Ok(head)
    }

-
    fn set_identity_head(&self) -> Result<Oid, IdentityError> {
-
        let head = self.canonical_identity_head()?;
-

-
        log::debug!(target: "storage", "Setting ref: {} -> {}", *CANONICAL_IDENTITY, head);
+
    fn set_identity_head_to(&self, commit: Oid) -> Result<(), RepositoryError> {
+
        log::debug!(target: "storage", "Setting ref: {} -> {}", *CANONICAL_IDENTITY, commit);
        self.raw().reference(
            CANONICAL_IDENTITY.as_str(),
-
            *head,
+
            *commit,
            true,
            "set-local-branch (radicle)",
        )?;
-

-
        Ok(head)
+
        Ok(())
    }

    fn raw(&self) -> &git2::Repository {
@@ -670,7 +645,11 @@ impl WriteRepository for Repository {
impl SignRepository for Repository {
    fn sign_refs<G: Signer>(&self, signer: &G) -> Result<SignedRefs<Verified>, Error> {
        let remote = signer.public_key();
-
        let refs = self.references_of(remote)?;
+
        let mut refs = self.references_of(remote)?;
+
        // Don't sign the `rad/sigrefs` ref itself, and don't sign invalid OIDs.
+
        refs.retain(|name, oid| {
+
            name.as_refstr() != refs::SIGREFS_BRANCH.as_ref() && !oid.is_zero()
+
        });
        let signed = refs.signed(signer)?;

        signed.save(self)?;
@@ -839,6 +818,7 @@ pub mod paths {
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod tests {
    use crypto::test::signer::MockSigner;

@@ -1057,11 +1037,13 @@ mod tests {

        transport::local::register(storage.clone());

-
        let (id, _, _, _) =
+
        let (rid, _, _, _) =
            fixtures::project(tmp.path().join("project"), &storage, &signer).unwrap();
-
        let proj = storage.repository(id).unwrap();
+
        let repo = storage.repository(rid).unwrap();
+
        let id = repo.identity().unwrap().head();
+
        let cob = format!("refs/cobs/xyz.radicle.id/{id}");

-
        let mut refs = proj
+
        let mut refs = repo
            .references_of(signer.public_key())
            .unwrap()
            .iter()
@@ -1071,7 +1053,7 @@ mod tests {

        assert_eq!(
            refs,
-
            vec!["refs/heads/master", "refs/rad/id", "refs/rad/sigrefs"]
+
            vec![&cob, "refs/heads/master", "refs/rad/id", "refs/rad/sigrefs"]
        );
    }

modified radicle/src/storage/git/cob.rs
@@ -1,9 +1,11 @@
//! COB storage Git backend.
use std::collections::BTreeMap;
+
use std::path::Path;

use cob::object::Objects;
use radicle_cob as cob;
use radicle_cob::change;
+
use storage::RepositoryError;
use storage::SignRepository;

use crate::git::*;
@@ -15,11 +17,13 @@ use crate::storage::{
};
use crate::{
    git, identity,
-
    identity::{doc::DocError, IdentityError, PublicKey},
+
    identity::{doc::DocError, PublicKey},
};

use super::{RemoteId, Repository};

+
pub use crate::cob::{store, ObjectId};
+

#[derive(Error, Debug)]
pub enum ObjectsError {
    #[error(transparent)]
@@ -54,7 +58,7 @@ impl change::Storage for Repository {

    fn store<Signer>(
        &self,
-
        authority: Self::Parent,
+
        authority: Option<Self::Parent>,
        parents: Vec<Self::Parent>,
        signer: &Signer,
        spec: change::Template<Self::ObjectId>,
@@ -194,7 +198,7 @@ impl<'a, R: storage::WriteRepository> change::Storage for DraftStore<'a, R> {

    fn store<Signer>(
        &self,
-
        authority: Self::Parent,
+
        authority: Option<Self::Parent>,
        parents: Vec<Self::Parent>,
        signer: &Signer,
        spec: change::Template<Self::ObjectId>,
@@ -236,11 +240,11 @@ impl<'a, R: storage::ReadRepository> ReadRepository for DraftStore<'a, R> {
        self.repo.is_empty()
    }

-
    fn head(&self) -> Result<(fmt::Qualified, Oid), identity::IdentityError> {
+
    fn head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
        self.repo.head()
    }

-
    fn canonical_head(&self) -> Result<(fmt::Qualified, Oid), identity::IdentityError> {
+
    fn canonical_head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
        self.repo.canonical_head()
    }

@@ -275,11 +279,11 @@ impl<'a, R: storage::ReadRepository> ReadRepository for DraftStore<'a, R> {
        self.repo.is_ancestor_of(ancestor, head)
    }

-
    fn blob_at<'b>(
-
        &'b self,
+
    fn blob_at<P: AsRef<Path>>(
+
        &self,
        oid: git_ext::Oid,
-
        path: &'b std::path::Path,
-
    ) -> Result<git2::Blob<'b>, git_ext::Error> {
+
        path: P,
+
    ) -> Result<git2::Blob, git_ext::Error> {
        self.repo.blob_at(oid, path)
    }

@@ -314,24 +318,31 @@ impl<'a, R: storage::ReadRepository> ReadRepository for DraftStore<'a, R> {
        self.repo.references_glob(pattern)
    }

-
    fn identity_doc(
-
        &self,
-
    ) -> Result<(Oid, crate::identity::Doc<crate::crypto::Unverified>), IdentityError> {
+
    fn identity_doc(&self) -> Result<crate::identity::DocAt, RepositoryError> {
        self.repo.identity_doc()
    }

-
    fn identity_doc_at(
-
        &self,
-
        head: Oid,
-
    ) -> Result<crate::identity::Doc<crate::crypto::Unverified>, DocError> {
+
    fn identity_doc_at(&self, head: Oid) -> Result<crate::identity::DocAt, DocError> {
        self.repo.identity_doc_at(head)
    }

-
    fn identity_head(&self) -> Result<Oid, IdentityError> {
+
    fn identity_head(&self) -> Result<Oid, RepositoryError> {
        self.repo.identity_head()
    }

-
    fn canonical_identity_head(&self) -> Result<Oid, IdentityError> {
+
    fn identity_head_of(&self, remote: &RemoteId) -> Result<Oid, super::ext::Error> {
+
        self.repo.identity_head_of(remote)
+
    }
+

+
    fn identity_root(&self) -> Result<Oid, RepositoryError> {
+
        self.repo.identity_root()
+
    }
+

+
    fn identity_root_of(&self, remote: &RemoteId) -> Result<Oid, RepositoryError> {
+
        self.repo.identity_root_of(remote)
+
    }
+

+
    fn canonical_identity_head(&self) -> Result<Oid, RepositoryError> {
        self.repo.canonical_identity_head()
    }

modified radicle/src/storage/git/transport/local/url.rs
@@ -101,6 +101,7 @@ impl FromStr for Url {
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;

modified radicle/src/storage/git/transport/remote/url.rs
@@ -103,6 +103,7 @@ impl FromStr for Url {
}

#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
mod test {
    use super::*;

modified radicle/src/storage/refs.rs
@@ -139,11 +139,8 @@ impl Refs {

    pub fn canonical(&self) -> Vec<u8> {
        let mut buf = String::new();
-
        let refs = self
-
            .iter()
-
            .filter(|(name, oid)| name.as_refstr() != SIGREFS_BRANCH.as_ref() && !oid.is_zero());

-
        for (name, oid) in refs {
+
        for (name, oid) in self.iter() {
            buf.push_str(&oid.to_string());
            buf.push(' ');
            buf.push_str(name);
modified radicle/src/test/arbitrary.rs
@@ -12,7 +12,7 @@ use qcheck::Arbitrary;
use crate::collections::RandomMap;
use crate::identity::doc::Visibility;
use crate::identity::{
-
    doc::{Doc, Id},
+
    doc::{Doc, DocAt, Id},
    project::Project,
    Did,
};
@@ -68,9 +68,7 @@ pub fn vec<T: Eq + Arbitrary>(size: usize) -> Vec<T> {
pub fn nonempty_storage(size: usize) -> MockStorage {
    let mut storage = gen::<MockStorage>(size);
    for _ in 0..size {
-
        storage
-
            .inventory
-
            .insert(gen::<Id>(1), gen::<Doc<Verified>>(1));
+
        storage.inventory.insert(gen::<Id>(1), gen::<DocAt>(1));
    }
    storage
}
@@ -155,6 +153,18 @@ impl Arbitrary for Doc<Verified> {
    }
}

+
impl Arbitrary for DocAt {
+
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
+
        let doc = Doc::<Verified>::arbitrary(g);
+

+
        DocAt {
+
            commit: self::oid(),
+
            blob: self::oid(),
+
            doc,
+
        }
+
    }
+
}
+

impl Arbitrary for SignedRefs<Unverified> {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let bytes: [u8; 64] = Arbitrary::arbitrary(g);
modified radicle/src/test/storage.rs
@@ -6,8 +6,7 @@ use std::str::FromStr;
use git_ext::ref_format as fmt;

use crate::crypto::{Signer, Verified};
-
use crate::identity::doc::{Doc, DocError, Id};
-
use crate::identity::IdentityError;
+
use crate::identity::doc::{Doc, DocAt, DocError, Id};
use crate::node::NodeId;

pub use crate::storage::*;
@@ -15,7 +14,7 @@ pub use crate::storage::*;
#[derive(Clone, Debug)]
pub struct MockStorage {
    pub path: PathBuf,
-
    pub inventory: HashMap<Id, Doc<Verified>>,
+
    pub inventory: HashMap<Id, DocAt>,

    /// All refs keyed by RID.
    /// Each value is a map of refs keyed by node Id (public key).
@@ -23,7 +22,7 @@ pub struct MockStorage {
}

impl MockStorage {
-
    pub fn new(inventory: Vec<(Id, Doc<Verified>)>) -> Self {
+
    pub fn new(inventory: Vec<(Id, DocAt)>) -> Self {
        Self {
            path: PathBuf::default(),
            inventory: inventory.into_iter().collect(),
@@ -64,14 +63,10 @@ impl ReadStorage for MockStorage {
        self.path().join(rid.canonical())
    }

-
    fn contains(&self, rid: &Id) -> Result<bool, IdentityError> {
+
    fn contains(&self, rid: &Id) -> Result<bool, RepositoryError> {
        Ok(self.inventory.contains_key(rid))
    }

-
    fn get(&self, _remote: &RemoteId, proj: Id) -> Result<Option<Doc<Verified>>, IdentityError> {
-
        Ok(self.inventory.get(&proj).cloned())
-
    }
-

    fn inventory(&self) -> Result<Inventory, Error> {
        Ok(self.inventory.keys().cloned().collect::<Vec<_>>())
    }
@@ -109,15 +104,21 @@ impl WriteStorage for MockStorage {
#[derive(Clone, Debug)]
pub struct MockRepository {
    id: Id,
-
    doc: Doc<Verified>,
+
    doc: DocAt,
    remotes: HashMap<NodeId, refs::SignedRefs<Verified>>,
}

impl MockRepository {
    pub fn new(id: Id, doc: Doc<Verified>) -> Self {
+
        let (blob, _) = doc.encode().unwrap();
+

        Self {
            id,
-
            doc,
+
            doc: DocAt {
+
                commit: Oid::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap(),
+
                blob,
+
                doc,
+
            },
            remotes: HashMap::default(),
        }
    }
@@ -132,11 +133,11 @@ impl ReadRepository for MockRepository {
        Ok(self.remotes.is_empty())
    }

-
    fn head(&self) -> Result<(fmt::Qualified, Oid), IdentityError> {
+
    fn head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
        todo!()
    }

-
    fn canonical_head(&self) -> Result<(fmt::Qualified, Oid), IdentityError> {
+
    fn canonical_head(&self) -> Result<(fmt::Qualified, Oid), RepositoryError> {
        todo!()
    }

@@ -182,11 +183,11 @@ impl ReadRepository for MockRepository {
        todo!()
    }

-
    fn blob_at<'a>(
-
        &'a self,
+
    fn blob_at<P: AsRef<std::path::Path>>(
+
        &self,
        _oid: git_ext::Oid,
-
        _path: &'a std::path::Path,
-
    ) -> Result<git2::Blob<'a>, git_ext::Error> {
+
        _path: P,
+
    ) -> Result<git2::Blob, git_ext::Error> {
        todo!()
    }

@@ -217,24 +218,31 @@ impl ReadRepository for MockRepository {
        todo!()
    }

-
    fn identity_doc(
-
        &self,
-
    ) -> Result<(Oid, crate::identity::Doc<crate::crypto::Unverified>), IdentityError> {
-
        Ok((git2::Oid::zero().into(), self.doc.clone().unverified()))
+
    fn identity_doc(&self) -> Result<crate::identity::DocAt, RepositoryError> {
+
        Ok(self.doc.clone())
    }

-
    fn identity_doc_at(
-
        &self,
-
        _head: Oid,
-
    ) -> Result<crate::identity::Doc<crate::crypto::Unverified>, DocError> {
-
        Ok(self.doc.clone().unverified())
+
    fn identity_doc_at(&self, _head: Oid) -> Result<crate::identity::DocAt, DocError> {
+
        Ok(self.doc.clone())
    }

-
    fn identity_head(&self) -> Result<Oid, IdentityError> {
+
    fn identity_head(&self) -> Result<Oid, RepositoryError> {
        self.canonical_identity_head()
    }

-
    fn canonical_identity_head(&self) -> Result<Oid, IdentityError> {
+
    fn identity_head_of(&self, _remote: &RemoteId) -> Result<Oid, git::ext::Error> {
+
        todo!()
+
    }
+

+
    fn identity_root(&self) -> Result<Oid, RepositoryError> {
+
        todo!()
+
    }
+

+
    fn identity_root_of(&self, _remote: &RemoteId) -> Result<Oid, RepositoryError> {
+
        todo!()
+
    }
+

+
    fn canonical_identity_head(&self) -> Result<Oid, RepositoryError> {
        Ok(Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap())
    }

@@ -248,11 +256,11 @@ impl WriteRepository for MockRepository {
        todo!()
    }

-
    fn set_head(&self) -> Result<Oid, IdentityError> {
+
    fn set_head(&self) -> Result<Oid, RepositoryError> {
        todo!()
    }

-
    fn set_identity_head(&self) -> Result<Oid, IdentityError> {
+
    fn set_identity_head_to(&self, _commit: Oid) -> Result<(), RepositoryError> {
        todo!()
    }
}