Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: refactor doc
Merged fintohaps opened 1 year ago

The aim of this change is to make the Doc type more safe to use by approaching the design via [Parse don’t validate][0] approach.

The problem with the previous approach was that all field were pub and thus a Doc<Verified> could easily be mutated and serialized. Granted, the code that used the serialization would tend to verify the Doc first, however, this approach ensures that only a verified Doc can be serialized. It also meant that trying to add new data that would follow the parse approach would require more generic parameters on top of the existing PhantomData parameter, i.e. we need to do something like: Doc<RawField, V> -> Doc<ValidField, V>.

The new approach splits the type into two separate types: DocMut and Doc. The former is allowed to be mutated at will, and uses types that are less strict. The latter is the valid type that can only be constructed by validating a DocMut (or the initial constructor). The Doc type’s fields can then only be accessed by read-only methods.

Solves the problems above by only allowing mutations to DocMut, as well as, new fields being added to DocMut which are then validated via DocMut::verified.

28 files changed +810 -361 f83c1167 de1958fa
modified radicle-cli/src/commands/checkout.rs
@@ -84,11 +84,10 @@ 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: Doc<_> = storage
+
    let doc = storage
        .repository(id)?
        .identity_doc()
-
        .context("repository could not be found in local storage")?
-
        .into();
+
        .context("repository could not be found in local storage")?;
    let payload = doc.project()?;
    let path = PathBuf::from(payload.name());

@@ -115,7 +114,8 @@ fn execute(options: Options, profile: &Profile) -> anyhow::Result<PathBuf> {
    spinner.finish();

    let remotes = doc
-
        .delegates
+
        .delegates()
+
        .clone()
        .into_iter()
        .map(|did| *did)
        .filter(|id| id != profile.id())
modified radicle-cli/src/commands/clone.rs
@@ -153,7 +153,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        &profile.storage,
    )?;
    let delegates = doc
-
        .delegates
+
        .delegates()
        .iter()
        .map(|d| **d)
        .filter(|id| id != profile.id())
@@ -240,7 +240,7 @@ pub fn clone<G: Signer>(
    (
        raw::Repository,
        storage::git::Repository,
-
        Doc<Verified>,
+
        Doc,
        Project,
    ),
    CloneError,
modified radicle-cli/src/commands/id.rs
@@ -4,14 +4,12 @@ use std::{ffi::OsString, io};

use anyhow::{anyhow, Context};

-
use nonempty::NonEmpty;
use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
-
use radicle::identity::{doc, Identity, Visibility};
-
use radicle::prelude::{Did, Doc, RepoId, Signer};
+
use radicle::identity::{doc, Doc, Identity, RawDoc, Visibility};
+
use radicle::prelude::{Did, RepoId, Signer};
use radicle::storage::refs;
use radicle::storage::{ReadRepository, 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;
@@ -389,7 +387,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            payload,
        } => {
            let proposal = {
-
                let mut proposal = current.doc.clone();
+
                let mut proposal = current.doc.clone().edit();
                proposal.threshold = threshold.unwrap_or(proposal.threshold);

                if !allow.is_disjoint(&disallow) {
@@ -429,18 +427,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                        proposal.visibility = Visibility::Public;
                    }
                }
-
                proposal.delegates = NonEmpty::from_vec(
-
                    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"
-
                ))?;
-

+
                proposal.delegates = proposal
+
                    .delegates
+
                    .into_iter()
+
                    .chain(delegates)
+
                    .filter(|d| !rescind.contains(d))
+
                    .collect::<Vec<_>>();
                if let Some(errs) = verify_delegates(&proposal, &repo)? {
                    term::error(format!("failed to verify delegates for {rid}"));
                    term::error(format!(
@@ -475,6 +467,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                }
                proposal
            };
+
            let proposal = proposal.verified()?;
            if proposal == current.doc {
                if !options.quiet {
                    term::print(term::format::italic(
@@ -578,11 +571,7 @@ fn get<'a>(
    Ok(revision)
}

-
fn print_meta(
-
    revision: &Revision,
-
    previous: &Doc<Verified>,
-
    profile: &Profile,
-
) -> anyhow::Result<()> {
+
fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow::Result<()> {
    let mut attrs = term::Table::<2, term::Label>::new(Default::default());

    attrs.push([
@@ -630,7 +619,7 @@ fn print_meta(
    let accepted = revision.accepted().collect::<Vec<_>>();
    let rejected = revision.rejected().collect::<Vec<_>>();
    let unknown = previous
-
        .delegates
+
        .delegates()
        .iter()
        .filter(|id| !accepted.contains(id) && !rejected.contains(id))
        .collect::<Vec<_>>();
@@ -712,7 +701,7 @@ and description.
fn update<R: WriteRepository + cob::Store, G: Signer>(
    title: Option<String>,
    description: Option<String>,
-
    doc: Doc<Verified>,
+
    doc: Doc,
    current: &mut IdentityMut<R>,
    signer: &G,
) -> anyhow::Result<Revision> {
@@ -734,14 +723,14 @@ fn print_diff(
    repo: &radicle::storage::git::Repository,
) -> anyhow::Result<()> {
    let previous = if let Some(previous) = previous {
-
        let previous = Doc::<Verified>::load_at(*previous, repo)?;
+
        let previous = Doc::load_at(*previous, repo)?;
        let previous = serde_json::to_string_pretty(&previous.doc)?;

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

    let tmp = tempfile::tempdir()?;
@@ -798,8 +787,8 @@ impl VerificationError {
    }
}

-
fn verify_delegates<S, V>(
-
    proposal: &Doc<V>,
+
fn verify_delegates<S>(
+
    proposal: &RawDoc,
    repo: &S,
) -> anyhow::Result<Option<Vec<VerificationError>>>
where
modified radicle-cli/src/commands/init.rs
@@ -10,15 +10,14 @@ use std::str::FromStr;
use anyhow::{anyhow, bail, Context as _};
use serde_json as json;

-
use radicle::crypto::{ssh, Verified};
+
use radicle::crypto::ssh;
use radicle::explorer::ExplorerUrl;
use radicle::git::RefString;
use radicle::identity::project::ProjectName;
-
use radicle::identity::{RepoId, Visibility};
+
use radicle::identity::{RepoId, Doc, Visibility};
use radicle::node::events::UploadPack;
use radicle::node::policy::Scope;
use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
-
use radicle::prelude::Doc;
use radicle::storage::ReadStorage as _;
use radicle::{profile, Node};

@@ -302,7 +301,7 @@ pub fn init(
            if options.seed {
                profile.seed(rid, options.scope, &mut node)?;

-
                if doc.visibility.is_public() {
+
                if doc.is_public() {
                    profile.add_inventory(rid, &mut node)?;
                }
            }
@@ -529,11 +528,11 @@ fn sync(

pub fn announce(
    rid: RepoId,
-
    doc: Doc<Verified>,
+
    doc: Doc,
    node: &mut Node,
    config: &profile::Config,
) -> anyhow::Result<()> {
-
    if doc.visibility.is_public() {
+
    if doc.is_public() {
        match sync(rid, node, config) {
            Ok(SyncResult::Synced {
                result: Some(url), ..
@@ -597,7 +596,7 @@ pub fn announce(
    } else {
        term::info!(
            "You have created a {} repository.",
-
            term::format::visibility(&doc.visibility)
+
            term::format::visibility(doc.visibility())
        );
        term::info!(
            "This repository will only be visible to you, \
modified radicle-cli/src/commands/inspect.rs
@@ -152,7 +152,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        }
        Target::Payload => {
            let (_, doc) = repo(rid, storage)?;
-
            json::to_pretty(&doc.payload, Path::new("radicle.json"))?.print();
+
            json::to_pretty(&doc.payload(), Path::new("radicle.json"))?.print();
        }
        Target::Identity => {
            let (_, doc) = repo(rid, storage)?;
@@ -195,7 +195,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        Target::Delegates => {
            let (_, doc) = repo(rid, storage)?;
            let aliases = profile.aliases();
-
            for did in &doc.delegates {
+
            for did in doc.delegates().iter() {
                if let Some(alias) = aliases.alias(did) {
                    println!(
                        "{} {}",
@@ -209,7 +209,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        }
        Target::Visibility => {
            let (_, doc) = repo(rid, storage)?;
-
            println!("{}", term::format::visibility(&doc.visibility));
+
            println!("{}", term::format::visibility(doc.visibility()));
        }
        Target::History => {
            let (repo, _) = repo(rid, storage)?;
modified radicle-cli/src/commands/ls.rs
@@ -105,10 +105,10 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        ..
    } in repos
    {
-
        if doc.visibility.is_public() && options.private && !options.public {
+
        if doc.is_public() && options.private && !options.public {
            continue;
        }
-
        if !doc.visibility.is_public() && !options.private && options.public {
+
        if !doc.is_public() && !options.private && options.public {
            continue;
        }
        if refs.is_none() && !options.all && !options.seeded {
@@ -135,7 +135,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            term::format::bold(proj.name().to_owned()),
            term::format::tertiary(rid.urn()),
            if seeded {
-
                term::format::visibility(&doc.visibility).into()
+
                term::format::visibility(doc.visibility()).into()
            } else {
                term::format::dim("local").into()
            },
modified radicle-cli/src/commands/publish.rs
@@ -77,19 +77,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

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

-
    if doc.visibility.is_public() {
+
    if doc.is_public() {
        return Err(Error::WithHint {
            err: anyhow!("repository is already public"),
            hint: "to announce the repository to the network, run `rad sync --inventory`",
        }
        .into());
    }
-
    if !doc.is_delegate(profile.id()) {
+
    if !doc.is_delegate(&profile.id().into()) {
        return Err(anyhow!("only the repository delegate can publish it"));
    }
-
    if doc.delegates.len() > 1 {
+
    if doc.delegates().len() > 1 {
        return Err(Error::WithHint {
            err: anyhow!(
                "only repositories with a single delegate can be published with this command"
@@ -101,7 +101,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let signer = profile.signer()?;

    // Update identity document.
-
    doc.visibility = Visibility::Public;
+
    let doc = doc.clone().with_edits(|doc| {
+
        doc.visibility = Visibility::Public;
+
    })?;

    identity.update("Publish repository", "", &doc, &signer)?;
    repo.sign_refs(&signer)?;
@@ -123,7 +125,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

    term::success!(
        "Repository is now {}",
-
        term::format::visibility(&doc.visibility)
+
        term::format::visibility(doc.visibility())
    );

    if !node.is_running() {
modified radicle-cli/src/commands/seed.rs
@@ -169,7 +169,7 @@ pub fn update(
    let outcome = if updated { "updated" } else { "exists" };

    if let Ok(repo) = profile.storage.repository(rid) {
-
        if repo.identity_doc()?.visibility.is_public() {
+
        if repo.identity_doc()?.is_public() {
            profile.add_inventory(rid, node)?;
            term::success!("Inventory updated with {}", term::format::tertiary(rid));
        }
modified radicle-cli/src/node.rs
@@ -180,7 +180,7 @@ fn announce_<R: ReadRepository>(
    let rid = repo.id();
    let doc = repo.identity_doc()?;
    let mut settings = settings.with_profile(profile);
-
    let unsynced: Vec<_> = if doc.visibility.is_public() {
+
    let unsynced: Vec<_> = if doc.is_public() {
        // All seeds.
        let all = node.seeds(rid)?;
        if all.is_empty() {
@@ -218,7 +218,7 @@ fn announce_<R: ReadRepository>(
    } else {
        node.sessions()?
            .into_iter()
-
            .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid))
+
            .filter(|s| s.state.is_connected() && doc.is_visible_to(&s.nid.into()))
            .map(|s| s.nid)
            .collect()
    };
modified radicle-fetch/src/handle.rs
@@ -2,10 +2,9 @@ use std::sync::atomic::{self, AtomicBool};
use std::sync::Arc;

use bstr::BString;
-
use radicle::crypto::{PublicKey, Verified};
+
use radicle::crypto::PublicKey;
use radicle::git::Oid;
-
use radicle::identity::DocError;
-
use radicle::prelude::Doc;
+
use radicle::identity::{DocError, Doc};
use radicle::storage::git::Repository;
use radicle::storage::ReadRepository;

@@ -74,7 +73,7 @@ impl<S> Handle<S> {
        self.interrupt.store(true, atomic::Ordering::Relaxed);
    }

-
    pub fn verified(&self, head: Oid) -> Result<Doc<Verified>, DocError> {
+
    pub fn verified(&self, head: Oid) -> Result<Doc, DocError> {
        Ok(self.repo.identity_doc_at(head)?.doc)
    }

modified radicle-fetch/src/stage.rs
@@ -207,8 +207,8 @@ impl ProtocolStage for CanonicalId {
                remote: self.remote,
                err: Box::new(err),
            })?;
-
        if verified.delegates.contains(&self.remote.into()) {
-
            let is_delegate = |remote: &PublicKey| verified.is_delegate(remote);
+
        if verified.is_delegate(&self.remote.into()) {
+
            let is_delegate = |remote: &PublicKey| verified.is_delegate(&remote.into());
            Ok(Updates::build(
                refs.iter()
                    .filter_map(|r| r.as_special_ref_update(is_delegate)),
modified radicle-fetch/src/state.rs
@@ -4,7 +4,7 @@ use std::time::Instant;
use gix_protocol::handshake;
use radicle::crypto::PublicKey;
use radicle::git::{Oid, Qualified};
-
use radicle::identity::{Did, Doc, DocError};
+
use radicle::identity::{Did, DocError, Doc};

use radicle::prelude::Verified;
use radicle::storage;
@@ -389,11 +389,11 @@ impl FetchState {
            .canonical()?
            .ok_or(error::Protocol::MissingRadId)?;

-
        let is_delegate = anchor.delegates.contains(&Did::from(handle.local()));
+
        let is_delegate = anchor.is_delegate(&Did::from(handle.local()));
        // TODO: not sure we should allow to block *any* peer from the
        // delegate set. We could end up ignoring delegates.
        let delegates = anchor
-
            .delegates
+
            .delegates()
            .iter()
            .filter(|id| !handle.is_blocked(id))
            .map(|did| PublicKey::from(*did))
@@ -404,9 +404,9 @@ impl FetchState {
        // The local peer does not need to count towards the threshold
        // since they must be valid already.
        let threshold = if is_delegate {
-
            anchor.threshold - 1
+
            anchor.threshold() - 1
        } else {
-
            anchor.threshold
+
            anchor.threshold()
        };
        let signed_refs = self.run_special_refs(
            handle,
@@ -637,11 +637,11 @@ impl<'a, S> Cached<'a, S> {
        self.state.canonical_rad_id().copied()
    }

-
    pub fn verified(&self, head: Oid) -> Result<Doc<Verified>, DocError> {
+
    pub fn verified(&self, head: Oid) -> Result<Doc, DocError> {
        self.handle.verified(head)
    }

-
    pub fn canonical(&self) -> Result<Option<Doc<Verified>>, error::Canonical> {
+
    pub fn canonical(&self) -> Result<Option<Doc>, error::Canonical> {
        let tip = self.refname_to_id(refs::REFS_RAD_ID.clone())?;
        let cached_tip = self.canonical_rad_id();

@@ -736,7 +736,7 @@ impl<'a, S> ValidateRepository for Cached<'a, S> {
/// N.b. if the repository does not have the project payload or a
/// deserialization error occurs, then this will return `None`.
fn validate_project_default_branch(
-
    anchor: &Doc<Verified>,
+
    anchor: &Doc,
    sigrefs: &SignedRefs<Verified>,
) -> Option<Validation> {
    let proj = anchor.project().ok()?;
modified radicle-node/src/service.rs
@@ -22,6 +22,7 @@ use localtime::{LocalDuration, LocalTime};
use log::*;
use nonempty::NonEmpty;

+
use radicle::identity::Doc;
use radicle::node;
use radicle::node::address;
use radicle::node::address::Store as _;
@@ -36,8 +37,8 @@ use radicle::storage::refs::SIGREFS_BRANCH;
use radicle::storage::RepositoryError;
use radicle_fetch::policy::SeedingPolicy;

-
use crate::crypto::{Signer, Verified};
-
use crate::identity::{Doc, RepoId};
+
use crate::crypto::Signer;
+
use crate::identity::RepoId;
use crate::node::routing;
use crate::node::routing::InsertResult;
use crate::node::{
@@ -678,7 +679,7 @@ where
                continue;
            }
            // Add public repositories to inventory.
-
            if repo.doc.visibility.is_public() {
+
            if repo.doc.is_public() {
                inventory.insert(rid);
            } else {
                private.insert(rid);
@@ -1168,7 +1169,7 @@ where

                // Announce our new inventory if this fetch was a full clone.
                // Only update and announce inventory for public repositories.
-
                if clone && doc.visibility.is_public() {
+
                if clone && doc.is_public() {
                    debug!(target: "service", "Updating and announcing inventory for cloned repository {rid}..");

                    if let Err(e) = self.add_inventory(rid) {
@@ -2128,7 +2129,7 @@ where
    }

    /// Announce our own refs for the given repo.
-
    fn announce_own_refs(&mut self, rid: RepoId, doc: Doc<Verified>) -> Result<Vec<RefsAt>, Error> {
+
    fn announce_own_refs(&mut self, rid: RepoId, doc: Doc) -> Result<Vec<RefsAt>, Error> {
        let (refs, timestamp) = self.announce_refs(rid, doc, [self.node_id()])?;

        // Update refs database with our signed refs branches.
@@ -2161,7 +2162,7 @@ where
    fn announce_refs(
        &mut self,
        rid: RepoId,
-
        doc: Doc<Verified>,
+
        doc: Doc,
        remotes: impl IntoIterator<Item = NodeId>,
    ) -> Result<(Vec<RefsAt>, Timestamp), Error> {
        let (ann, refs) = self.refs_announcement_for(rid, remotes)?;
@@ -2192,7 +2193,7 @@ where
            ann,
            peers.filter(|p| {
                // Only announce to peers who are allowed to view this repo.
-
                doc.is_visible_to(&p.id)
+
                doc.is_visible_to(&p.id.into())
            }),
            self.db.gossip_mut(),
        );
@@ -2341,7 +2342,7 @@ where
                        .get(rid)
                        .ok()
                        .flatten()
-
                        .map(|doc| doc.is_visible_to(id))
+
                        .map(|doc| doc.is_visible_to(&(*id).into()))
                        .unwrap_or(false)
                } else {
                    // Announcement doesn't concern a specific repository, let it through.
@@ -2634,7 +2635,7 @@ pub trait ServiceState {
    /// Get event emitter.
    fn emitter(&self) -> &Emitter<Event>;
    /// Get a repository from storage.
-
    fn get(&self, rid: RepoId) -> Result<Option<Doc<Verified>>, RepositoryError>;
+
    fn get(&self, rid: RepoId) -> Result<Option<Doc>, RepositoryError>;
    /// Get the clock.
    fn clock(&self) -> &LocalTime;
    /// Get the clock mutably.
@@ -2675,7 +2676,7 @@ where
        &self.emitter
    }

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

@@ -2751,7 +2752,7 @@ impl fmt::Display for DisconnectReason {
#[derive(Debug)]
pub struct Lookup {
    /// Whether the project was found locally or not.
-
    pub local: Option<Doc<Verified>>,
+
    pub local: Option<Doc>,
    /// A list of remote peers on which the project is known to exist.
    pub remote: Vec<NodeId>,
}
modified radicle-node/src/tests.rs
@@ -712,7 +712,13 @@ fn test_refs_announcement_relay_public() {
    // Pretend Alice cloned Bob's repos.
    let repos = gen::<[MockRepository; 3]>(1);
    for (i, mut repo) in repos.into_iter().enumerate() {
-
        repo.doc.doc.visibility = Visibility::Public; // Public repos are always gossiped.
+
        repo.doc.doc = repo
+
            .doc
+
            .doc
+
            .with_edits(|doc| {
+
                doc.visibility = Visibility::Public; // Public repos are always gossiped.
+
            })
+
            .unwrap();
        alice.storage_mut().repos.insert(bob_inv[i], repo);
    }
    assert_matches!(
@@ -784,14 +790,32 @@ fn test_refs_announcement_relay_private() {
    alice.receive(eve.id(), Message::Subscribe(Subscribe::all()));

    // The first repo is not visible to Eve.
-
    let mut repo1 = gen::<MockRepository>(1);
-
    repo1.doc.doc.visibility = Visibility::Private { allow: [].into() };
+
    let repo1 = {
+
        let mut repo = gen::<MockRepository>(1);
+
        repo.doc.doc = repo
+
            .doc
+
            .doc
+
            .with_edits(|doc| {
+
                doc.visibility = Visibility::Private { allow: [].into() };
+
            })
+
            .unwrap();
+
        repo
+
    };
    alice.storage_mut().repos.insert(bob_inv[0], repo1);

    // The second repo is visible to Eve.
-
    let mut repo2 = gen::<MockRepository>(1);
-
    repo2.doc.doc.visibility = Visibility::Private {
-
        allow: [eve.id.into()].into(),
+
    let repo2 = {
+
        let mut repo = gen::<MockRepository>(1);
+
        repo.doc.doc = repo
+
            .doc
+
            .doc
+
            .with_edits(|doc| {
+
                doc.visibility = Visibility::Private {
+
                    allow: [eve.id.into()].into(),
+
                };
+
            })
+
            .unwrap();
+
        repo
    };
    alice.storage_mut().repos.insert(bob_inv[1], repo2);
    alice.elapse(service::GOSSIP_INTERVAL);
modified radicle-node/src/worker.rs
@@ -287,7 +287,8 @@ impl Worker {
        }
        let repo = self.storage.repository(rid)?;
        let doc = repo.identity_doc()?;
-
        if !doc.is_visible_to(&remote) {
+

+
        if !doc.is_visible_to(&remote.into()) {
            Err(UploadError::Unauthorized(remote, rid))
        } else {
            Ok(())
modified radicle-remote-helper/src/push.rs
@@ -266,8 +266,12 @@ pub fn run(
                        if dst == canonical_ref && delegates.contains(&me) && delegates.len() > 1 {
                            let head = working.find_reference(src.as_str())?;
                            let head = head.peel_to_commit()?.id();
-
                            let mut canonical =
-
                                Canonical::default_branch(stored, &project, &identity.delegates)?;
+

+
                            let mut canonical = Canonical::default_branch(
+
                                stored,
+
                                &project,
+
                                identity.delegates().as_ref(),
+
                            )?;
                            let converges = canonical::converges(
                                canonical
                                    .tips()
@@ -279,7 +283,7 @@ pub fn run(
                                canonical.modify_vote(me, head.into());
                            }

-
                            match canonical.quorum(identity.threshold, &working) {
+
                            match canonical.quorum(identity.threshold(), &working) {
                                Ok(canonical_oid) => {
                                    // Canonical head is an ancestor of head.
                                    let is_ff = head == *canonical_oid
modified radicle/src/cob/identity.rs
@@ -4,12 +4,13 @@ use std::{fmt, ops::Deref, str::FromStr};
use crypto::{PublicKey, Signature};
use once_cell::sync::Lazy;
use radicle_cob::{Embed, ObjectId, TypeName};
-
use radicle_crypto::{Signer, Verified};
+
use radicle_crypto::Signer;
use radicle_git_ext as git_ext;
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

+
use crate::identity::doc::Doc;
use crate::{
    cob,
    cob::{
@@ -18,7 +19,7 @@ use crate::{
        ActorId, Timestamp, Uri,
    },
    identity::{
-
        doc::{Doc, DocError, RepoId},
+
        doc::{DocError, RepoId},
        Did,
    },
    storage::{ReadRepository, RepositoryError, WriteRepository},
@@ -171,7 +172,7 @@ impl Identity {
            root,
            current: root,
            heads: revision
-
                .delegates
+
                .delegates()
                .iter()
                .copied()
                .map(|did| (did, root))
@@ -182,7 +183,7 @@ impl Identity {
    }

    pub fn initialize<'a, R: WriteRepository + cob::Store, G: Signer>(
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
        store: &'a R,
        signer: &G,
    ) -> Result<IdentityMut<'a, R>, cob::store::Error> {
@@ -249,7 +250,7 @@ impl Identity {
    }

    /// The current document.
-
    pub fn doc(&self) -> &Doc<Verified> {
+
    pub fn doc(&self) -> &Doc {
        &self.current().doc
    }

@@ -320,7 +321,7 @@ impl store::Cob for Identity {
                "the first operation must contain only one action",
            ));
        }
-
        let root = Doc::<Verified>::load_at(op.id, repo)?;
+
        let root = Doc::load_at(op.id, repo)?;
        if root.blob != blob {
            return Err(ApplyError::Init("invalid object id specified in revision"));
        }
@@ -331,7 +332,7 @@ impl store::Cob for Identity {
        }
        assert_eq!(root.commit, op.id);

-
        let founder = root.delegates.first();
+
        let founder = root.delegates().first();
        if founder.as_key() != &op.author {
            return Err(ApplyError::Init("delegate does not match committer"));
        }
@@ -411,7 +412,7 @@ impl Identity {
    ) -> Result<(), ApplyError> {
        let current = self.current().clone();

-
        if !current.is_delegate(&author) {
+
        if !current.is_delegate(&author.into()) {
            return Err(ApplyError::UnexpectedState);
        }
        match action {
@@ -674,7 +675,7 @@ pub struct Revision {
    /// Author of this proposed revision.
    pub author: Author,
    /// New [`Doc`] that will replace `previous`' document.
-
    pub doc: Doc<Verified>,
+
    pub doc: Doc,
    /// Physical timestamp of this proposal revision.
    pub timestamp: Timestamp,
    /// Parent revision.
@@ -685,7 +686,7 @@ pub struct Revision {
}

impl std::ops::Deref for Revision {
-
    type Target = Doc<Verified>;
+
    type Target = Doc;

    fn deref(&self) -> &Self::Target {
        &self.doc
@@ -736,7 +737,7 @@ impl Revision {
        description: String,
        author: Author,
        blob: Oid,
-
        doc: Doc<Verified>,
+
        doc: Doc,
        state: State,
        signature: Signature,
        parent: Option<RevisionId>,
@@ -788,7 +789,7 @@ impl Revision {
        // 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() {
+
        if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() {
            self.state = State::Rejected;
        }
        Ok(())
@@ -834,7 +835,7 @@ impl<R: WriteRepository> store::Transaction<Identity, R> {
        &mut self,
        title: impl ToString,
        description: impl ToString,
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
        parent: Option<RevisionId>,
        repo: &R,
        signer: &G,
@@ -914,7 +915,7 @@ where
        &mut self,
        title: impl ToString,
        description: impl ToString,
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
        signer: &G,
    ) -> Result<RevisionId, Error> {
        let parent = self.current;
@@ -1045,7 +1046,7 @@ mod test {
        let bob = MockSigner::default();
        let signer = &node.signer;
        let mut identity = Identity::load_mut(&*repo).unwrap();
-
        let mut doc = identity.doc().clone();
+
        let mut doc = identity.doc().clone().edit();
        let title = "Identity update";
        let description = "";
        let r0 = identity.current;
@@ -1054,33 +1055,38 @@ mod test {
        assert!(identity.current().is_accepted());
        // Using an identical document to the current one fails.
        identity
-
            .update(title, description, &doc, signer)
+
            .update(title, description, &doc.clone().verified().unwrap(), 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);
+
        assert!(doc.clone().verified().is_err());
+

        // Let's add another delegate.
-
        doc.delegate(bob.public_key());
+
        doc.delegate(bob.public_key().into());
        // The update should go through now.
-
        let r1 = identity.update(title, description, &doc, signer).unwrap();
+
        let r1 = identity
+
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .unwrap();
        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();
+
        let r2 = identity
+
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .unwrap();
        // R1 is still the head.
        assert_eq!(identity.current, r1);
        assert_eq!(identity.revision(&r2).unwrap().state, State::Active);
        assert_eq!(repo.canonical_identity_head().unwrap(), r1);
-
        assert_eq!(repo.identity_doc().unwrap().visibility, Visibility::Public);
+
        assert_eq!(
+
            repo.identity_doc().unwrap().visibility(),
+
            &Visibility::Public
+
        );
        // Now let's add a signature on R2 from Bob.
        identity.accept(&r2, &bob).unwrap();

@@ -1089,8 +1095,8 @@ mod test {
        assert_eq!(identity.revision(&r2).unwrap().state, State::Accepted);
        assert_eq!(repo.canonical_identity_head().unwrap(), r2);
        assert_eq!(
-
            repo.canonical_identity_doc().unwrap().visibility,
-
            Visibility::private([])
+
            repo.canonical_identity_doc().unwrap().visibility(),
+
            &Visibility::private([])
        );
    }

@@ -1101,18 +1107,25 @@ mod test {
        let eve = MockSigner::default();
        let signer = &node.signer;
        let mut identity = Identity::load_mut(&*repo).unwrap();
-
        let mut doc = identity.doc().clone();
+
        let mut doc = identity.doc().clone().edit();
        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();
+
        doc.delegate(bob.public_key().into());
+
        let r1 = identity
+
            .update(title, description, &doc.clone().verified().unwrap(), signer)
+
            .unwrap();
        assert_eq!(identity.current, r1);

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

        // 1/2 rejected means that we can never reach the required 2/2 votes.
@@ -1121,16 +1134,26 @@ mod test {
        assert_eq!(r2.state, State::Rejected);

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

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

        // 1/3 rejected means that we can still reach the 2/3 required votes.
@@ -1151,27 +1174,42 @@ mod test {
        let bob = &network.bob;

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

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

        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()));
+
        assert!(bob_doc.is_delegate(&bob.signer.public_key().into()));

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

        // Bob gets Alice's data.
@@ -1203,17 +1241,27 @@ mod test {
        let eve = &network.eve;

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

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

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

        bob.repo.fetch(alice);
@@ -1242,18 +1290,28 @@ mod test {
        let eve = &network.eve;

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

-
        alice_doc.delegate(bob.signer.public_key());
-
        alice_doc.delegate(eve.signer.public_key());
+
        alice_doc.delegate(bob.signer.public_key().into());
+
        alice_doc.delegate(eve.signer.public_key().into());
        let a0 = alice_identity.root;
        let a1 = alice_identity // Change description to change traversal order.
-
            .update("Add Bob and Eve", "Eh#!", &alice_doc, &alice.signer)
+
            .update(
+
                "Add Bob and Eve",
+
                "Eh#!",
+
                &alice_doc.clone().verified().unwrap(),
+
                &alice.signer,
+
            )
            .unwrap();

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

        bob.repo.fetch(eve);
@@ -1265,10 +1323,15 @@ mod test {
        assert_eq!(bob_identity.current, a2);

        let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap();
-
        let mut eve_doc = eve_identity.doc().clone();
+
        let mut eve_doc = eve_identity.doc().clone().edit();
        eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]);
        let e1 = eve_identity
-
            .update("Change visibility", "", &eve_doc, &eve.signer)
+
            .update(
+
                "Change visibility",
+
                "",
+
                &eve_doc.verified().unwrap(),
+
                &eve.signer,
+
            )
            .unwrap();
        // Eve's revision is active.
        assert!(eve_identity.revision(&e1).unwrap().is_active());
@@ -1288,7 +1351,7 @@ mod test {
        // her revision is no longer valid.
        assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]);
        assert_eq!(eve_identity.revision(&e1), None);
-
        assert!(!eve_identity.is_delegate(eve.signer.public_key()));
+
        assert!(!eve_identity.is_delegate(&eve.signer.public_key().into()));
    }

    #[test]
@@ -1299,18 +1362,28 @@ mod test {
        let eve = &network.eve;

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

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

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

        bob.repo.fetch(eve);
@@ -1327,10 +1400,15 @@ mod test {
        assert!(eve_identity.revision(&a2).unwrap().is_active());

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

@@ -1368,23 +1446,28 @@ mod test {
        let eve = &network.eve;

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

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

        bob.repo.fetch(alice);
        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()));
+
        let mut bob_doc = bob_identity.doc().clone().edit();
+
        assert!(bob_doc.is_delegate(&bob.signer.public_key().into()));

        //  a2 e1
        //  | /
@@ -1397,17 +1480,27 @@ mod test {
        // 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)
+
            .update(
+
                "Change visibility #1",
+
                "",
+
                &bob_doc.verified().unwrap(),
+
                &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();
+
        let mut eve_doc = eve_identity.doc().clone().edit();
        eve_doc.visibility = Visibility::private([]);
        let e1 = eve_identity
-
            .update("Change visibility #2", "Woops", &eve_doc, &eve.signer)
+
            .update(
+
                "Change visibility #2",
+
                "Woops",
+
                &eve_doc.verified().unwrap(),
+
                &eve.signer,
+
            )
            .unwrap();
        assert_eq!(eve_identity.revisions().count(), 4);
        assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Active);
@@ -1441,27 +1534,37 @@ mod test {

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

        // 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)
+
            .update(
+
                "Update description",
+
                "",
+
                &doc.clone().verified().unwrap(),
+
                &alice,
+
            )
            .unwrap();

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

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

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

        // Update description again with signatures by Eve and Bob.
@@ -1470,7 +1573,12 @@ mod test {
        doc.payload.insert(PayloadId::project(), prj.into());

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

modified radicle/src/cob/issue.rs
@@ -16,8 +16,8 @@ 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::identity::doc::{Doc, DocError};
-
use crate::prelude::{Did, ReadRepository, RepoId, Verified};
+
use crate::identity::doc::DocError;
+
use crate::prelude::{Did, Doc, ReadRepository, RepoId};
use crate::storage::{HasRepoId, RepositoryError, WriteRepository};

pub use cache::Cache;
@@ -325,9 +325,9 @@ impl Issue {
        &self,
        action: &Action,
        actor: &ActorId,
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
    ) -> Result<Authorization, Error> {
-
        if doc.is_delegate(actor) {
+
        if doc.is_delegate(&actor.into()) {
            // A delegate is authorized to do all actions.
            return Ok(Authorization::Allow);
        }
@@ -388,7 +388,7 @@ impl Issue {
        author: ActorId,
        timestamp: Timestamp,
        _concurrent: &[&cob::Entry],
-
        _doc: &Doc<Verified>,
+
        _doc: &Doc,
        _repo: &R,
    ) -> Result<(), Error> {
        match action {
modified radicle/src/cob/patch.rs
@@ -630,9 +630,9 @@ impl Patch {
        &self,
        action: &Action,
        actor: &ActorId,
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
    ) -> Result<Authorization, Error> {
-
        if doc.is_delegate(actor) {
+
        if doc.is_delegate(&actor.into()) {
            // A delegate is authorized to do all actions.
            return Ok(Authorization::Allow);
        }
@@ -745,7 +745,7 @@ impl Patch {
        author: ActorId,
        timestamp: Timestamp,
        _concurrent: &[&cob::Entry],
-
        identity: &Doc<Verified>,
+
        identity: &Doc,
        repo: &R,
    ) -> Result<(), Error> {
        match action {
@@ -1046,7 +1046,7 @@ impl Patch {
                    },
                );
                // Discard revisions that weren't merged by a threshold of delegates.
-
                merges.retain(|_, count| *count >= identity.threshold);
+
                merges.retain(|_, count| *count >= identity.threshold());

                match merges.into_keys().collect::<Vec<_>>().as_slice() {
                    [] => {
@@ -2970,9 +2970,9 @@ mod test {
        let oid = git::Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap();
        let mut alice = Actor::new(MockSigner::default());
        let rid = gen::<RepoId>(1);
-
        let doc = Doc::new(
+
        let doc = RawDoc::new(
            gen::<Project>(1),
-
            nonempty::NonEmpty::new(alice.did()),
+
            vec![alice.did()],
            1,
            identity::Visibility::Public,
        )
modified radicle/src/identity.rs
@@ -5,7 +5,7 @@ pub mod project;

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

pub use crate::cob::identity::{Error, Identity, IdentityMut};
modified radicle/src/identity/doc.rs
@@ -2,7 +2,7 @@ mod id;

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
-
use std::marker::PhantomData;
+
use std::num::NonZeroUsize;
use std::ops::{Deref, Not};
use std::path::Path;
use std::str::FromStr;
@@ -17,7 +17,7 @@ use thiserror::Error;
use crate::canonical::formatter::CanonicalFormatter;
use crate::cob::identity;
use crate::crypto;
-
use crate::crypto::{Signature, Unverified, Verified};
+
use crate::crypto::Signature;
use crate::git;
use crate::identity::{project::Project, Did};
use crate::storage;
@@ -37,10 +37,10 @@ pub const MAX_DELEGATES: usize = 255;
pub enum DocError {
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
-
    #[error("invalid delegates: {0}")]
-
    Delegates(&'static str),
-
    #[error("invalid threshold `{0}`: {1}")]
-
    Threshold(usize, &'static str),
+
    #[error(transparent)]
+
    Delegates(#[from] DelegatesError),
+
    #[error(transparent)]
+
    Threshold(#[from] ThresholdError),
    #[error("git: {0}")]
    GitExt(#[from] git::Error),
    #[error("git: {0}")]
@@ -49,6 +49,14 @@ pub enum DocError {
    Missing,
}

+
#[derive(Debug, Error)]
+
#[error("invalid delegates: {0}")]
+
pub struct DelegatesError(&'static str);
+

+
#[derive(Debug, Error)]
+
#[error("invalid threshold `{0}`: {1}")]
+
pub struct ThresholdError(usize, &'static str);
+

impl DocError {
    /// Whether this error is caused by the document not being found.
    pub fn is_not_found(&self) -> bool {
@@ -99,7 +107,9 @@ pub enum PayloadError {
    NotFound(PayloadId),
}

-
/// Payload value.
+
/// A `Payload` is a free-form JSON value that can be associated with an
+
/// identity's [`Doc`].
+
/// The payload is identified in the [`Doc`] by its corresponding [`PayloadId`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Payload {
@@ -137,25 +147,25 @@ pub struct DocAt {
    /// The document blob at this commit.
    pub blob: Oid,
    /// The parsed document.
-
    pub doc: Doc<Verified>,
+
    pub doc: Doc,
}

impl Deref for DocAt {
-
    type Target = Doc<Verified>;
+
    type Target = Doc;

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

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

-
impl AsRef<Doc<Verified>> for DocAt {
-
    fn as_ref(&self) -> &Doc<Verified> {
+
impl AsRef<Doc> for DocAt {
+
    fn as_ref(&self) -> &Doc {
        &self.doc
    }
}
@@ -209,43 +219,410 @@ impl Visibility {
    }
}

-
/// An identity document.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
/// `RawDoc` is similar to the [`Doc`] type, however, it can be edited and may
+
/// not be valid.
+
///
+
/// It is expected that any changes to a [`Doc`] are made via [`RawDoc`], and
+
/// then verified by using [`RawDoc::verified`].
+
///
+
/// Note that `RawDoc` only implements [`Deserialize`]. This prevents us from
+
/// serializing an unverified document, while also making sure that any document
+
/// that is deserialized is verified.
+
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
-
pub struct Doc<V> {
+
pub struct RawDoc {
    /// The payload section.
    pub payload: BTreeMap<PayloadId, Payload>,
    /// The delegates section.
-
    pub delegates: NonEmpty<Did>,
+
    pub delegates: Vec<Did>,
    /// The signature threshold.
    pub threshold: usize,
    /// Repository visibility.
-
    #[serde(default, skip_serializing_if = "Visibility::is_public")]
+
    #[serde(default)]
    pub visibility: Visibility,
+
}
+

+
impl TryFrom<RawDoc> for Doc {
+
    type Error = DocError;
+

+
    fn try_from(doc: RawDoc) -> Result<Self, Self::Error> {
+
        doc.verified()
+
    }
+
}
+

+
impl RawDoc {
+
    /// Construct a new [`RawDoc`] with an initial [`RawDoc::payload`]
+
    /// containing the provided [`Project`], and the given `delegates`,
+
    /// `threshold`, and `visibility`.
+
    pub fn new(
+
        project: Project,
+
        delegates: Vec<Did>,
+
        threshold: usize,
+
        visibility: Visibility,
+
    ) -> Self {
+
        let project =
+
            serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
+

+
        Self {
+
            payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
+
            delegates,
+
            threshold,
+
            visibility,
+
        }
+
    }
+

+
    /// Get the project payload, if it exists and is valid, out of this document.
+
    pub fn project(&self) -> Result<Project, PayloadError> {
+
        let value = self
+
            .payload
+
            .get(&PayloadId::project())
+
            .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
+
        let proj: Project = serde_json::from_value((**value).clone())?;
+

+
        Ok(proj)
+
    }
+

+
    /// Check if the given `did` is in the set of [`RawDoc::delegates`].
+
    pub fn is_delegate(&self, did: &Did) -> bool {
+
        self.delegates.contains(did)
+
    }
+

+
    /// Add a new delegate to the document.
+
    ///
+
    /// Note that if this `Did` is a duplicate, then the resulting set will only
+
    /// show it once.
+
    pub fn delegate(&mut self, did: Did) {
+
        self.delegates.push(did)
+
    }
+

+
    /// Remove the `did` from the set of delegates. Returns `true` if it was
+
    /// removed.
+
    pub fn rescind(&mut self, did: &Did) -> Result<bool, DocError> {
+
        let (matches, delegates) = self.delegates.iter().partition(|d| *d == did);
+
        self.delegates = delegates;
+
        Ok(matches.is_empty().not())
+
    }
+

+
    /// Construct the `RawDoc` from the set of `bytes` that are expected to be
+
    /// in JSON format.
+
    pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
+
        serde_json::from_slice(bytes).map_err(DocError::from)
+
    }
+

+
    /// Verify the `RawDoc`'s values, converting it into a valid [`Doc`].
+
    ///
+
    /// The verifications are as follows:
+
    ///
+
    ///  - [`RawDoc::delegates`]: any duplicates are removed, and for the
+
    ///    remaining set ensure that it is non-empty and does not exceed a
+
    ///    length of [`MAX_DELEGATES`].
+
    ///  - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`.
+
    pub fn verified(self) -> Result<Doc, DocError> {
+
        let RawDoc {
+
            payload,
+
            delegates,
+
            threshold,
+
            visibility,
+
        } = self;
+
        let delegates = Delegates::new(delegates)?;
+
        let threshold = Threshold::new(threshold, &delegates)?;
+
        Ok(Doc {
+
            payload,
+
            delegates,
+
            threshold,
+
            visibility,
+
        })
+
    }
+
}

-
    #[serde(skip)]
-
    verified: PhantomData<V>,
+
/// A valid set of delegates for the identity [`Doc`].
+
///
+
/// It can only be constructed via [`Delegates::new`].
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(try_from = "Vec<Did>")]
+
pub struct Delegates(NonEmpty<Did>);
+

+
impl AsRef<NonEmpty<Did>> for Delegates {
+
    fn as_ref(&self) -> &NonEmpty<Did> {
+
        &self.0
+
    }
+
}
+

+
impl TryFrom<Vec<Did>> for Delegates {
+
    type Error = DelegatesError;
+

+
    fn try_from(dids: Vec<Did>) -> Result<Self, Self::Error> {
+
        Delegates::new(dids)
+
    }
+
}
+

+
impl IntoIterator for Delegates {
+
    type Item = <NonEmpty<Did> as IntoIterator>::Item;
+
    type IntoIter = <NonEmpty<Did> as IntoIterator>::IntoIter;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.0.into_iter()
+
    }
+
}
+

+
impl Delegates {
+
    /// Construct the set of `Delegates` by removing any duplicate [`Did`]s,
+
    /// ensure that the set is non-empty, and check the length does not exceed
+
    /// the [`MAX_DELEGATES`].
+
    pub fn new(delegates: impl IntoIterator<Item = Did>) -> Result<Self, DelegatesError> {
+
        let delegates = delegates
+
            .into_iter()
+
            .try_fold(Vec::<Did>::new(), |mut dids, did| {
+
                if !dids.contains(&did) {
+
                    if dids.len() >= MAX_DELEGATES {
+
                        return Err(DelegatesError("number of delegates cannot exceed 255"));
+
                    }
+
                    dids.push(did);
+
                }
+
                Ok(dids)
+
            })?;
+
        NonEmpty::from_vec(delegates)
+
            .map(Self)
+
            .ok_or(DelegatesError("delegate list cannot be empty"))
+
    }
+

+
    /// Get the first delegate in the set.
+
    pub fn first(&self) -> &Did {
+
        self.0.first()
+
    }
+

+
    /// Obtain an iterator over the [`Did`]s.
+
    pub fn iter(&self) -> impl Iterator<Item = &Did> {
+
        self.0.iter()
+
    }
+

+
    /// Check if the set contains the given `did`.
+
    pub fn contains(&self, did: &Did) -> bool {
+
        self.0.contains(did)
+
    }
+

+
    /// Get the number of delegates in the set.
+
    pub fn len(&self) -> usize {
+
        self.0.len()
+
    }
+

+
    /// Check if the set is empty. Note that this always returns `false`.
+
    pub fn is_empty(&self) -> bool {
+
        false
+
    }
}

-
impl<V> Doc<V> {
-
    /// Check whether this document and the associated repository is visible to the given peer.
-
    pub fn is_visible_to(&self, peer: &PublicKey) -> bool {
+
impl<'a> From<&'a Delegates> for &'a NonEmpty<Did> {
+
    fn from(ds: &'a Delegates) -> Self {
+
        &ds.0
+
    }
+
}
+

+
impl From<Delegates> for NonEmpty<Did> {
+
    fn from(ds: Delegates) -> Self {
+
        ds.0
+
    }
+
}
+

+
impl From<Delegates> for Vec<Did> {
+
    fn from(Delegates(ds): Delegates) -> Self {
+
        ds.into()
+
    }
+
}
+

+
/// A valid threshold for the identity [`Doc`].
+
///
+
/// It can only be constructed via [`Threshold::new`] or [`Threshold::MIN`].
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
+
#[serde(transparent)]
+
pub struct Threshold(NonZeroUsize);
+

+
impl From<Threshold> for usize {
+
    fn from(Threshold(t): Threshold) -> Self {
+
        t.get()
+
    }
+
}
+

+
impl AsRef<NonZeroUsize> for Threshold {
+
    fn as_ref(&self) -> &NonZeroUsize {
+
        &self.0
+
    }
+
}
+

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

+
impl Threshold {
+
    /// A threshold of `1`.
+
    pub const MIN: Threshold = Threshold(NonZeroUsize::MIN);
+

+
    /// Construct the `Threshold` by checking that `t` is not greater than
+
    /// [`MAX_DELEGATES`], that it does not exceed the number of delegates, and
+
    /// is non-zero.
+
    pub fn new(t: usize, delegates: &Delegates) -> Result<Self, ThresholdError> {
+
        if t > MAX_DELEGATES {
+
            Err(ThresholdError(t, "threshold cannot exceed 255"))
+
        } else if t > delegates.len() {
+
            Err(ThresholdError(
+
                t,
+
                "threshold cannot exceed number of delegates",
+
            ))
+
        } else {
+
            NonZeroUsize::new(t)
+
                .map(Self)
+
                .ok_or(ThresholdError(t, "threshold cannot be zero"))
+
        }
+
    }
+
}
+

+
/// `Doc` is a valid identity document.
+
///
+
/// To ensure that only valid documents are used, this type is restricted to be
+
/// read-only. For mutating the document use [`Doc::edit`].
+
///
+
/// A valid `Doc` can be constructed in four ways:
+
///
+
///   1. [`Doc::initial`]: a safe way to construct the initial document for an identity.
+
///   2. [`RawDoc::verified`]: validates a [`RawDoc`]'s fields and converts it
+
///      into a `Doc`
+
///   3. [`Deserialize`]: will deserialize a `Doc` by first deserializing a
+
///      [`RawDoc`] and use [`RawDoc::verified`] to construct the `Doc`.
+
///   4. [`Doc::from_blob`]: construct a `Doc` from a Git blob by deserializing
+
///      its contents.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
#[serde(try_from = "RawDoc")]
+
pub struct Doc {
+
    payload: BTreeMap<PayloadId, Payload>,
+
    delegates: Delegates,
+
    threshold: Threshold,
+
    #[serde(default, skip_serializing_if = "Visibility::is_public")]
+
    visibility: Visibility,
+
}
+

+
impl Doc {
+
    /// Construct the initial [`Doc`] for an identity.
+
    ///
+
    /// It will begin with the provided `project` in the [`Doc::payload`], the
+
    /// `delegate` as the sole delegate, a threshold of 1, and the given
+
    /// `visibility`.
+
    pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self {
+
        let project =
+
            serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
+

+
        Self {
+
            payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
+
            delegates: Delegates(NonEmpty::new(delegate)),
+
            threshold: Threshold(NonZeroUsize::MIN),
+
            visibility,
+
        }
+
    }
+

+
    /// Construct a [`Doc`] contained in the provided Git blob.
+
    pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
+
        RawDoc::from_json(blob.content())?.verified()
+
    }
+

+
    /// Convert the [`Doc`] into a [`RawDoc`] for changing the field values and
+
    /// re-verifying.
+
    pub fn edit(self) -> RawDoc {
+
        let Doc {
+
            payload,
+
            delegates,
+
            threshold,
+
            visibility,
+
        } = self;
+
        RawDoc {
+
            payload,
+
            delegates: delegates.into(),
+
            threshold: threshold.into(),
+
            visibility,
+
        }
+
    }
+

+
    /// Using the current state of the `Doc`, perform any edits on the `RawDoc`
+
    /// form and verify the changes.
+
    pub fn with_edits<F>(self, f: F) -> Result<Self, DocError>
+
    where
+
        F: FnOnce(&mut RawDoc),
+
    {
+
        let mut raw = self.edit();
+
        f(&mut raw);
+
        raw.verified()
+
    }
+

+
    /// Return the associated payloads for this [`Doc`].
+
    pub fn payload(&self) -> &BTreeMap<PayloadId, Payload> {
+
        &self.payload
+
    }
+

+
    /// Get the project payload, if it exists and is valid, out of this document.
+
    pub fn project(&self) -> Result<Project, PayloadError> {
+
        let value = self
+
            .payload
+
            .get(&PayloadId::project())
+
            .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
+
        let proj: Project = serde_json::from_value((**value).clone())?;
+

+
        Ok(proj)
+
    }
+

+
    /// Return the associated [`Visibility`] of this document.
+
    pub fn visibility(&self) -> &Visibility {
+
        &self.visibility
+
    }
+

+
    /// Check whether the visibility of the document is public.
+
    pub fn is_public(&self) -> bool {
+
        self.visibility.is_public()
+
    }
+

+
    /// Check whether the visibility of the document is private.
+
    pub fn is_private(&self) -> bool {
+
        self.visibility.is_private()
+
    }
+

+
    /// Return the associated threshold of this document.
+
    pub fn threshold(&self) -> usize {
+
        self.threshold.into()
+
    }
+

+
    /// Return the associated threshold of this document in its non-zero format.
+
    pub fn threshold_nonzero(&self) -> &NonZeroUsize {
+
        &self.threshold.0
+
    }
+

+
    /// Return the associated delegates of this document.
+
    pub fn delegates(&self) -> &Delegates {
+
        &self.delegates
+
    }
+

+
    /// Check if the `did` is part of the [`Doc::delegates`] set.
+
    pub fn is_delegate(&self, did: &Did) -> bool {
+
        self.delegates.contains(did)
+
    }
+

+
    /// Check whether this document and the associated repository is visible to
+
    /// the given peer.
+
    pub fn is_visible_to(&self, did: &Did) -> bool {
        match &self.visibility {
            Visibility::Public => true,
-
            Visibility::Private { allow } => {
-
                allow.contains(&Did::from(*peer)) || self.is_delegate(peer)
-
            }
+
            Visibility::Private { allow } => allow.contains(did) || self.is_delegate(did),
        }
    }

-
    /// Validate signature using this document's delegates, against a given document blob.
+
    /// 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) {
+
        if !self.is_delegate(&key.into()) {
            return Err(*key);
        }
        if key.verify(blob.as_bytes(), signature).is_err() {
@@ -254,25 +631,27 @@ impl<V> Doc<V> {
        Ok(())
    }

+
    /// Check the provided `votes` passes the [`Doc::majority`].
    pub fn is_majority(&self, votes: usize) -> bool {
        votes >= self.majority()
    }

+
    /// Return the majority number based on the size of the delegates set.
    pub fn majority(&self) -> usize {
        self.delegates.len() / 2 + 1
    }

-
    pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
+
    /// Helper for getting an `embeds` Git blob.
+
    pub(crate) fn blob_at<R: ReadRepository>(
+
        commit: Oid,
+
        repo: &R,
+
    ) -> Result<git2::Blob, DocError> {
        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 {
-
        self.delegates.contains(&key.into())
-
    }
-
}
-

-
impl Doc<Verified> {
+
    /// Encode the [`Doc`] as canonical JSON, returning the set of bytes and its
+
    /// corresponding Git [`Oid`].
    pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), DocError> {
        let mut buf = Vec::new();
        let mut serializer =
@@ -284,45 +663,8 @@ impl Doc<Verified> {
        Ok((oid.into(), buf))
    }

-
    /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before.
-
    pub fn delegate(&mut self, key: &crypto::PublicKey) -> bool {
-
        let delegate = Did::from(key);
-
        if self.delegates.iter().all(|id| id != &delegate) {
-
            self.delegates.push(delegate);
-
            return true;
-
        }
-
        false
-
    }
-

-
    pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result<Option<Did>, DocError> {
-
        let delegate = Did::from(key);
-
        let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate);
-
        match NonEmpty::from_vec(delegates) {
-
            Some(delegates) => {
-
                self.delegates = delegates;
-
                if self.threshold > self.delegates.len() {
-
                    return Err(DocError::Threshold(
-
                        self.threshold,
-
                        "the thresholds exceeds the new delegate count after removal",
-
                    ));
-
                }
-
                Ok(matches.is_empty().not().then_some(delegate))
-
            }
-
            None => Err(DocError::Delegates("cannot remove the last delegate")),
-
        }
-
    }
-

-
    /// Get the project payload, if it exists and is valid, out of this document.
-
    pub fn project(&self) -> Result<Project, PayloadError> {
-
        let value = self
-
            .payload
-
            .get(&PayloadId::project())
-
            .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
-
        let proj: Project = serde_json::from_value((**value).clone())?;
-

-
        Ok(proj)
-
    }
-

+
    /// [`Doc::encode`] and sign the [`Doc`], returning the set of bytes, its
+
    /// corresponding Git [`Oid`] and the [`Signature`] over the [`Oid`].
    pub fn sign<G: crypto::Signer>(
        &self,
        signer: &G,
@@ -333,15 +675,18 @@ impl Doc<Verified> {
        Ok((oid, bytes, sig))
    }

+
    /// Similar to [`Doc::sign`], but only returning the [`Signature`].
    pub fn signature_of<G: crypto::Signer>(&self, signer: &G) -> Result<Signature, DocError> {
        let (_, _, sig) = self.sign(signer)?;

        Ok(sig)
    }

+
    /// Load the [`DocAt`] found at the given `commit`. The [`DocAt`] will
+
    /// contain the corresponding [`Doc`].
    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)?;
+
        let doc = Self::from_blob(&blob)?;

        Ok(DocAt {
            commit,
@@ -350,10 +695,8 @@ impl Doc<Verified> {
        })
    }

-
    pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
-
        Doc::from_json(blob.content())?.verified()
-
    }
-

+
    /// Initialize an [`identity::Identity`] with this [`Doc`] as the associated
+
    /// document.
    pub fn init<G: crypto::Signer>(
        &self,
        repo: &storage::git::Repository,
@@ -378,80 +721,55 @@ impl Doc<Verified> {
    }
}

-
impl Doc<Unverified> {
-
    pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self {
-
        Self::new(project, NonEmpty::new(delegate), 1, visibility)
-
    }
-

-
    pub fn new(
-
        project: Project,
-
        delegates: NonEmpty<Did>,
-
        threshold: usize,
-
        visibility: Visibility,
-
    ) -> Self {
-
        let project =
-
            serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
-

-
        Self {
-
            payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
-
            delegates,
-
            threshold,
-
            visibility,
-
            verified: PhantomData,
-
        }
-
    }
-

-
    pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
-
        serde_json::from_slice(bytes).map_err(DocError::from)
-
    }
-

-
    pub fn verified(self) -> Result<Doc<Verified>, DocError> {
-
        if self.delegates.len() > MAX_DELEGATES {
-
            return Err(DocError::Delegates("number of delegates cannot exceed 255"));
-
        }
-
        if self.delegates.is_empty() {
-
            return Err(DocError::Delegates("delegate list cannot be empty"));
-
        }
-
        if self.threshold > self.delegates.len() {
-
            return Err(DocError::Threshold(
-
                self.threshold,
-
                "threshold cannot exceed number of delegates",
-
            ));
-
        }
-
        if self.threshold == 0 {
-
            return Err(DocError::Threshold(
-
                self.threshold,
-
                "threshold cannot be zero",
-
            ));
-
        }
-

-
        Ok(Doc {
-
            payload: self.payload,
-
            delegates: self.delegates,
-
            threshold: self.threshold,
-
            visibility: self.visibility,
-
            verified: PhantomData,
-
        })
-
    }
-
}
-

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
    use radicle_crypto::test::signer::MockSigner;
    use radicle_crypto::Signer as _;

+
    use crate::assert_matches;
    use crate::rad;
    use crate::storage::git::transport;
    use crate::storage::git::Storage;
    use crate::storage::{ReadStorage as _, RemoteId, WriteStorage as _};
    use crate::test::arbitrary;
+
    use crate::test::arbitrary::gen;
    use crate::test::fixtures;

    use super::*;
    use qcheck_macros::quickcheck;

    #[test]
+
    fn test_duplicate_dids() {
+
        let delegate = MockSigner::from_seed([0xff; 32]);
+
        let did = Did::from(delegate.public_key());
+
        let mut doc = RawDoc::new(gen::<Project>(1), vec![did], 1, Visibility::Public);
+
        doc.delegate(did);
+
        let doc = doc.verified().unwrap();
+
        assert!(doc.delegates().len() == 1, "Duplicate DID was not removed");
+
        assert!(doc.delegates().first() == &did)
+
    }
+

+
    #[test]
+
    fn test_max_delegates() {
+
        // Generate more than the max delegates
+
        let delegates = (0..MAX_DELEGATES + 1).map(gen).collect::<Vec<Did>>();
+

+
        // A document with max delegates will be fine
+
        let doc = RawDoc::new(
+
            gen::<Project>(1),
+
            delegates[0..MAX_DELEGATES].into(),
+
            1,
+
            Visibility::Public,
+
        );
+
        assert_matches!(doc.verified(), Ok(_));
+

+
        // A document that exceeds max delegates should fail
+
        let doc = RawDoc::new(gen::<Project>(1), delegates, 1, Visibility::Public);
+
        assert_matches!(doc.verified(), Err(DocError::Delegates(DelegatesError(_))));
+
    }
+

+
    #[test]
    fn test_canonical_example() {
        let tempdir = tempfile::tempdir().unwrap();
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
@@ -494,7 +812,7 @@ mod test {
        let err = repo.identity_head_of(&remote).unwrap_err();
        matches!(err, git::ext::Error::NotFound(_));

-
        let err = Doc::<Verified>::load_at(oid.into(), &repo).unwrap_err();
+
        let err = Doc::load_at(oid.into(), &repo).unwrap_err();
        assert!(err.is_not_found());
    }

@@ -523,9 +841,9 @@ mod test {
    }

    #[quickcheck]
-
    fn prop_encode_decode(doc: Doc<Verified>) {
+
    fn prop_encode_decode(doc: Doc) {
        let (_, bytes) = doc.encode().unwrap();
-
        assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
+
        assert_eq!(RawDoc::from_json(&bytes).unwrap().verified().unwrap(), doc);
    }

    #[test]
modified radicle/src/lib.rs
@@ -40,7 +40,7 @@ pub mod prelude {
    use super::*;

    pub use crypto::{PublicKey, Signer, Verified};
-
    pub use identity::{project::Project, Did, Doc, RepoId};
+
    pub use identity::{project::Project, Did, Doc, RawDoc, RepoId};
    pub use node::{Alias, NodeId, Timestamp};
    pub use profile::Profile;
    pub use storage::{
modified radicle/src/rad.rs
@@ -52,7 +52,7 @@ pub fn init<G: Signer, S: WriteStorage>(
    visibility: Visibility,
    signer: &G,
    storage: S,
-
) -> Result<(RepoId, identity::Doc<Verified>, SignedRefs<Verified>), InitError> {
+
) -> Result<(RepoId, identity::Doc, SignedRefs<Verified>), InitError> {
    // TODO: Better error when project id already exists in storage, but remote doesn't.
    let delegate: identity::Did = signer.public_key().into();
    let proj = Project::new(
@@ -68,7 +68,7 @@ pub fn init<G: Signer, S: WriteStorage>(
                .join(", "),
        )
    })?;
-
    let doc = identity::Doc::initial(proj, delegate, visibility).verified()?;
+
    let doc = identity::Doc::initial(proj, delegate, visibility);
    let (project, identity) = Repository::init(&doc, &storage, signer)?;
    let url = git::Url::from(project.id);

@@ -452,7 +452,7 @@ mod tests {
        assert_eq!(project.name(), "acme");
        assert_eq!(project.description(), "Acme's repo");
        assert_eq!(project.default_branch(), &git::refname!("master"));
-
        assert_eq!(doc.delegates.first(), &Did::from(public_key));
+
        assert_eq!(doc.delegates().first(), &Did::from(public_key));
    }

    #[test]
modified radicle/src/storage.rs
@@ -32,13 +32,13 @@ pub type BranchName = git::RefString;

/// Basic repository information.
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct RepositoryInfo<V> {
+
pub struct RepositoryInfo {
    /// Repository identifier.
    pub rid: RepoId,
    /// Head of default branch.
    pub head: Oid,
    /// Identity document.
-
    pub doc: Doc<V>,
+
    pub doc: Doc,
    /// Local signed refs, if any.
    /// Repositories with this set to `None` are ones that are seeded but not forked.
    pub refs: Option<refs::SignedRefsAt>,
@@ -412,11 +412,11 @@ pub trait ReadStorage {
    /// Check whether storage contains a repository.
    fn contains(&self, rid: &RepoId) -> Result<bool, RepositoryError>;
    /// Return all repositories (public and private).
-
    fn repositories(&self) -> Result<Vec<RepositoryInfo<Verified>>, Error>;
+
    fn repositories(&self) -> Result<Vec<RepositoryInfo>, Error>;
    /// Open or create a read-only repository.
    fn repository(&self, rid: RepoId) -> Result<Self::Repository, RepositoryError>;
    /// Get a repository's identity if it exists.
-
    fn get(&self, rid: RepoId) -> Result<Option<Doc<Verified>>, RepositoryError> {
+
    fn get(&self, rid: RepoId) -> Result<Option<Doc>, RepositoryError> {
        match self.repository(rid) {
            Ok(repo) => Ok(Some(repo.identity_doc()?.into())),
            Err(e) if e.is_not_found() => Ok(None),
@@ -569,9 +569,9 @@ pub trait ReadRepository: Sized + ValidateRepository {

    /// Get repository delegates.
    fn delegates(&self) -> Result<NonEmpty<Did>, RepositoryError> {
-
        let doc: Doc<_> = self.identity_doc()?.into();
+
        let doc = self.identity_doc()?;

-
        Ok(doc.delegates)
+
        Ok(doc.delegates().clone().into())
    }

    /// Get the repository's identity document.
@@ -683,7 +683,7 @@ where
        self.deref().contains(rid)
    }

-
    fn get(&self, rid: RepoId) -> Result<Option<Doc<Verified>>, RepositoryError> {
+
    fn get(&self, rid: RepoId) -> Result<Option<Doc>, RepositoryError> {
        self.deref().get(rid)
    }

@@ -691,7 +691,7 @@ where
        self.deref().repository(rid)
    }

-
    fn repositories(&self) -> Result<Vec<RepositoryInfo<Verified>>, Error> {
+
    fn repositories(&self) -> Result<Vec<RepositoryInfo>, Error> {
        self.deref().repositories()
    }
}
modified radicle/src/storage/git.rs
@@ -11,10 +11,9 @@ use crypto::{Signer, Verified};
use once_cell::sync::Lazy;
use tempfile::TempDir;

-
use crate::crypto::Unverified;
use crate::git::canonical::Canonical;
use crate::identity::doc::DocError;
-
use crate::identity::{doc::DocAt, Doc, RepoId};
+
use crate::identity::{Doc, DocAt, RepoId};
use crate::identity::{Identity, Project};
use crate::node::SyncedAt;
use crate::storage::refs;
@@ -106,7 +105,7 @@ impl ReadStorage for Storage {
        Repository::open(paths::repository(self, &rid), rid)
    }

-
    fn repositories(&self) -> Result<Vec<RepositoryInfo<Verified>>, Error> {
+
    fn repositories(&self) -> Result<Vec<RepositoryInfo>, Error> {
        let mut repos = Vec::new();

        for result in fs::read_dir(&self.path)? {
@@ -238,7 +237,7 @@ impl Storage {
    pub fn repositories_by_id<'a>(
        &self,
        mut rids: impl Iterator<Item = &'a RepoId>,
-
    ) -> Result<Vec<RepositoryInfo<Verified>>, RepositoryError> {
+
    ) -> Result<Vec<RepositoryInfo>, RepositoryError> {
        rids.try_fold(Vec::new(), |mut infos, rid| {
            let repo = self.repository(*rid)?;
            let (_, head) = repo.head()?;
@@ -391,7 +390,7 @@ impl Repository {
        let delegates = self
            .delegates()?
            .into_iter()
-
            .map(RemoteId::from)
+
            .map(|did| *did)
            .collect::<BTreeSet<_>>();
        let mut deleted = Vec::new();
        for id in self.remote_ids()? {
@@ -435,7 +434,7 @@ impl Repository {

    /// Create the repository's identity branch.
    pub fn init<G: Signer, S: WriteStorage>(
-
        doc: &Doc<Verified>,
+
        doc: &Doc,
        storage: &S,
        signer: &G,
    ) -> Result<(Self, git::Oid), RepositoryError> {
@@ -491,7 +490,7 @@ impl Repository {
        Ok(proj)
    }

-
    pub fn identity_doc_of(&self, remote: &RemoteId) -> Result<Doc<Verified>, DocError> {
+
    pub fn identity_doc_of(&self, remote: &RemoteId) -> Result<Doc, DocError> {
        let oid = self.identity_head_of(remote)?;
        Doc::load_at(oid, self).map(|d| d.into())
    }
@@ -730,7 +729,7 @@ impl ReadRepository for Repository {
    }

    fn identity_doc_at(&self, head: Oid) -> Result<DocAt, DocError> {
-
        Doc::<Verified>::load_at(head, self)
+
        Doc::load_at(head, self)
    }

    fn head(&self) -> Result<(Qualified, Oid), RepositoryError> {
@@ -748,8 +747,8 @@ impl ReadRepository for Repository {
        let project = doc.project()?;
        let branch_ref = git::refs::branch(project.default_branch());
        let raw = self.raw();
-
        let oid = Canonical::default_branch(self, &project, &doc.delegates)?
-
            .quorum(doc.threshold, raw)?;
+
        let oid = Canonical::default_branch(self, &project, doc.delegates().into())?
+
            .quorum(doc.threshold(), raw)?;
        Ok((branch_ref, oid))
    }

@@ -794,7 +793,7 @@ impl ReadRepository for Repository {
            let Ok(root) = self.identity_root_of(&remote) else {
                continue;
            };
-
            let blob = Doc::<Unverified>::blob_at(root, self)?;
+
            let blob = Doc::blob_at(root, self)?;

            // We've got an identity that goes back to the correct root.
            if blob.id() == **self.id {
modified radicle/src/storage/refs.rs
@@ -498,7 +498,7 @@ mod tests {

        // Alice creates "paris" repo.
        let (paris_repo, paris_head) = fixtures::repository(tmp.path().join("paris"));
-
        let (paris_rid, mut paris_doc, _) = rad::init(
+
        let (paris_rid, paris_doc, _) = rad::init(
            &paris_repo,
            "paris".try_into().unwrap(),
            "Paris repository",
@@ -511,7 +511,7 @@ mod tests {

        // Alice creates "london" repo.
        let (london_repo, _london_head) = fixtures::repository(tmp.path().join("london"));
-
        let (london_rid, mut london_doc, _) = rad::init(
+
        let (london_rid, london_doc, _) = rad::init(
            &london_repo,
            "london".try_into().unwrap(),
            "London repository",
@@ -532,8 +532,16 @@ mod tests {

        // Bob is added to both repos as a delegate, by Alice.
        {
-
            paris_doc.delegates.push(bob.public_key().into());
-
            london_doc.delegates.push(bob.public_key().into());
+
            let paris_doc = paris_doc
+
                .with_edits(|doc| {
+
                    doc.delegates.push(bob.public_key().into());
+
                })
+
                .unwrap();
+
            let london_doc = london_doc
+
                .with_edits(|doc| {
+
                    doc.delegates.push(bob.public_key().into());
+
                })
+
                .unwrap();

            let mut paris_ident = Identity::load_mut(&paris).unwrap();
            let mut london_ident = Identity::load_mut(&london).unwrap();
modified radicle/src/test/arbitrary.rs
@@ -5,17 +5,16 @@ use std::str::FromStr;
use std::{iter, net};

use crypto::test::signer::MockSigner;
-
use crypto::{PublicKey, Unverified, Verified};
+
use crypto::{PublicKey, Unverified};
use cyphernet::addr::tor::OnionAddrV3;
use cyphernet::EcPk;
-
use nonempty::NonEmpty;
use qcheck::Arbitrary;

use crate::collections::RandomMap;
use crate::identity::doc::Visibility;
use crate::identity::project::ProjectName;
use crate::identity::{
-
    doc::{Doc, DocAt, RepoId},
+
    doc::{Doc, DocAt, RawDoc, RepoId},
    project::Project,
    Did,
};
@@ -139,28 +138,26 @@ impl Arbitrary for Visibility {
    }
}

-
impl Arbitrary for Doc<Unverified> {
+
impl Arbitrary for RawDoc {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let proj = Project::arbitrary(g);
        let delegate = Did::arbitrary(g);
        let visibility = Visibility::arbitrary(g);

-
        Self::initial(proj, delegate, visibility)
+
        Self::new(proj, vec![delegate], 1, visibility)
    }
}

-
impl Arbitrary for Doc<Verified> {
+
impl Arbitrary for Doc {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let mut rng = fastrand::Rng::with_seed(u64::arbitrary(g));
        let project = Project::arbitrary(g);
-
        let delegates: NonEmpty<_> = iter::repeat_with(|| Did::arbitrary(g))
+
        let delegates = iter::repeat_with(|| Did::arbitrary(g))
            .take(rng.usize(1..6))
-
            .collect::<Vec<_>>()
-
            .try_into()
-
            .unwrap();
+
            .collect::<Vec<_>>();
        let threshold = delegates.len() / 2 + 1;
        let visibility = Visibility::arbitrary(g);
-
        let doc: Doc<Unverified> = Doc::new(project, delegates, threshold, visibility);
+
        let doc = RawDoc::new(project, delegates, threshold, visibility);

        doc.verified().unwrap()
    }
@@ -168,7 +165,7 @@ impl Arbitrary for Doc<Verified> {

impl Arbitrary for DocAt {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
-
        let doc = Doc::<Verified>::arbitrary(g);
+
        let doc = Doc::arbitrary(g);

        DocAt {
            commit: self::oid(),
@@ -238,7 +235,7 @@ impl Arbitrary for MockStorage {
impl Arbitrary for MockRepository {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
        let rid = RepoId::arbitrary(g);
-
        let doc = Doc::<Verified>::arbitrary(g);
+
        let doc = Doc::arbitrary(g);

        Self::new(rid, doc)
    }
modified radicle/src/test/storage.rs
@@ -7,7 +7,7 @@ use std::str::FromStr;
use git_ext::ref_format as fmt;

use crate::crypto::{Signer, Verified};
-
use crate::identity::doc::{Doc, DocAt, DocError, RepoId};
+
use crate::identity::doc::{Doc, DocAt, DocError, RawDoc, RepoId};
use crate::node::NodeId;

pub use crate::storage::*;
@@ -51,9 +51,9 @@ impl MockStorage {
            .expect("MockStorage::repo_mut: repository does not exist")
    }

-
    pub fn map(mut self, f: impl Fn(&mut Doc<Verified>)) -> Self {
+
    pub fn map(mut self, f: impl Fn(&mut RawDoc)) -> Self {
        for repo in self.repos.values_mut() {
-
            f(&mut repo.doc.doc);
+
            repo.doc.doc = repo.doc.doc.clone().with_edits(|doc| f(doc)).unwrap();
        }
        self
    }
@@ -91,7 +91,7 @@ impl ReadStorage for MockStorage {
            .cloned()
    }

-
    fn repositories(&self) -> Result<Vec<RepositoryInfo<Verified>>, Error> {
+
    fn repositories(&self) -> Result<Vec<RepositoryInfo>, Error> {
        Ok(self
            .repos
            .iter()
@@ -135,7 +135,7 @@ pub struct MockRepository {
}

impl MockRepository {
-
    pub fn new(id: RepoId, doc: Doc<Verified>) -> Self {
+
    pub fn new(id: RepoId, doc: Doc) -> Self {
        let (blob, _) = doc.encode().unwrap();

        Self {