Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: canonical references payload
Fintan Halpenny committed 9 months ago
commit 7f646666bc9b830ab79c717bf541a6c80383599e
parent af6cf03acda43bc28ac7de854ec9a4768a06c397
22 files changed +581 -287
modified crates/radicle-cli/examples/git/git-push-amend.md
@@ -21,7 +21,7 @@ $ git commit --amend -m "Neue Änderungen" --allow-empty -q

``` ~alice (stderr)
$ git push rad master -f
-
✓ Canonical head updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
+
✓ Canonical reference refs/heads/master updated to 9170c8795d3a78f0381a0ffafb20ea69fb0f5b6b
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + fb25886...9170c87 master -> master (forced update)
modified crates/radicle-cli/examples/git/git-push-converge.md
@@ -91,8 +91,7 @@ commit:

``` ~alice (stderr)
$ git push rad -f
-
warn: could not determine canonical tip for `refs/heads/master`
-
warn: no commit found with at least 3 vote(s) (threshold not met)
+
warn: could not determine commit for canonical reference 'refs/heads/master', no commit with at least 3 vote(s) found (threshold not met)
warn: it is recommended to find a commit to agree upon
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
@@ -117,7 +116,7 @@ become the canonical `master`.

``` ~bob (stderr)
$ git push rad
-
✓ Canonical head updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
+
✓ Canonical reference refs/heads/master updated to 3a75f66dd0020c9a0355cc6ec21f15de989e2001
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
   2a37862..0f9bd80  master -> master
@@ -138,7 +137,7 @@ HEAD is now at 0f9bd80 Merge remote-tracking branch 'eve/master'

``` ~eve (stderr)
$ git push rad
-
✓ Canonical head updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
+
✓ Canonical reference refs/heads/master updated to 0f9bd8035c04b3f73f5408e73e8454879b20800b
✓ Synced with 2 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z
   3a75f66..0f9bd80  master -> master
modified crates/radicle-cli/examples/git/git-push-diverge.md
@@ -44,7 +44,7 @@ integrate Bob's changes before pushing ours:

``` ~alice (stderr) (fail) RAD_HINT=1
$ git push rad
-
hint: you are attempting to push a commit that would cause your upstream to diverge from the canonical head
+
hint: you are attempting to push a commit that would cause your upstream to diverge from the canonical reference refs/heads/master
hint: to integrate the remote changes, run `git pull --rebase` and try again
error: refusing to update branch to commit that is not a descendant of canonical head
error: failed to push some refs to 'rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi'
@@ -62,7 +62,7 @@ f2de534 Second commit
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push rad
-
✓ Canonical head updated to f6cff86594495e9beccfeda7c20173e55c1dd9fc
+
✓ Canonical reference refs/heads/master updated to f6cff86594495e9beccfeda7c20173e55c1dd9fc
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f6cff86  master -> master
```
@@ -75,7 +75,7 @@ $ git reset --hard HEAD^ -q
```
``` ~alice RAD_SOCKET=/dev/null (stderr)
$ git push -f
-
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
+
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + f6cff86...319a7dc master -> master (forced update)
```
modified crates/radicle-cli/examples/git/git-push-rollback.md
@@ -35,7 +35,7 @@ Fast-forward

``` ~alice (stderr)
$ git push rad
-
✓ Canonical head updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
+
✓ Canonical reference refs/heads/master updated to 319a7dc3b195368ded4b099f8c90bbb80addccd3
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..319a7dc  master -> master
@@ -54,7 +54,7 @@ push and the new canonical head becomes the previous commit again:

``` ~alice (stderr)
$ git push rad -f
-
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 319a7dc...f2de534 master -> master (forced update)
modified crates/radicle-cli/examples/rad-id-threshold.md
@@ -64,7 +64,7 @@ $ git commit -v -m "Define power requirements"

``` ~alice (stderr) RAD_SOCKET=/dev/null
$ git push rad master
-
✓ Canonical head updated to 3e674d1a1df90807e934f9ae5da2591dd6848a33
+
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..3e674d1  master -> master
```
modified crates/radicle-cli/examples/rad-merge-after-update.md
@@ -16,7 +16,7 @@ $ git commit --amend --allow-empty -q -m "Amended change"
$ git checkout master -q
$ git merge feature/1 -q
$ git push rad master
-
✓ Canonical head updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
+
✓ Canonical reference refs/heads/master updated to 954bcdb5008447ce294a61a21d7eb87afbe7f4a6
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..954bcdb  master -> master
```
modified crates/radicle-cli/examples/rad-merge-no-ff.md
@@ -37,7 +37,7 @@ Finally, we push master and expect the patch to be merged.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical head updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
+
✓ Canonical reference refs/heads/master updated to 737a10cfa29111afeb0d43cf3545cee386b939ec
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..737a10c  master -> master
```
modified crates/radicle-cli/examples/rad-merge-via-push.md
@@ -70,7 +70,7 @@ When we push to `rad/master`, we automatically merge the patches:
$ git push rad master
✓ Patch 356f73863a8920455ff6e77cd9c805d68910551b merged
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical head updated to d6399c71702b40bae00825b3c444478d06b4e91c
+
✓ Canonical reference refs/heads/master updated to d6399c71702b40bae00825b3c444478d06b4e91c
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..d6399c7  master -> master
```
@@ -148,7 +148,7 @@ the first patch, even though they were pushed together.
$ git reset --hard HEAD^
$ git push -f rad
! Patch 356f73863a8920455ff6e77cd9c805d68910551b reverted at revision 356f738
-
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + d6399c7...20aa5dd master -> master (forced update)
```
modified crates/radicle-cli/examples/rad-patch-merge-draft.md
@@ -14,7 +14,7 @@ $ git checkout master -q
$ git merge feature/1
$ git push rad master
✓ Patch 8dfb4dcafc4346158c8160410dd3f2b0616ad4fe merged
-
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..20aa5dd  master -> master
```
modified crates/radicle-cli/examples/rad-patch-open-explore.md
@@ -38,7 +38,7 @@ $ git checkout master -q
$ git merge changes -q
$ git push rad master
✓ Patch acab0ec777a97d013f30be5d5d1aec32562ecb02 merged
-
✓ Canonical head updated to b2b6432af93f8fe188e32d400263021b602cfec8
+
✓ Canonical reference refs/heads/master updated to b2b6432af93f8fe188e32d400263021b602cfec8
✓ Synced with 1 seed(s)

  https://app.radicle.xyz/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8
modified crates/radicle-cli/examples/rad-patch-revert-merge.md
@@ -12,7 +12,7 @@ Switched to branch 'master'
$ git merge feature/1
$ git push rad master
✓ Patch 696ec5508494692899337afe6713fe1796d0315c merged
-
✓ Canonical head updated to 20aa5dde6210796c3a2f04079b42316a31d02689
+
✓ Canonical reference refs/heads/master updated to 20aa5dde6210796c3a2f04079b42316a31d02689
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..20aa5dd  master -> master
```
@@ -50,7 +50,7 @@ When pushing, notice that we're told our patch is reverted.
``` (stderr) RAD_SOCKET=/dev/null
$ git push rad master --force
! Patch 696ec5508494692899337afe6713fe1796d0315c reverted at revision 696ec55
-
✓ Canonical head updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
+
✓ Canonical reference refs/heads/master updated to f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
 + 20aa5dd...f2de534 master -> master (forced update)
```
modified crates/radicle-cli/examples/workflow/5-patching-maintainer.md
@@ -92,7 +92,7 @@ Fast-forward
``` (stderr)
$ git push rad master
✓ Patch e4934b6d9dbe01ce3c7fbb5b77a80d5f1dacdc46 merged at revision 9d62420
-
✓ Canonical head updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
+
✓ Canonical reference refs/heads/master updated to f567f695d25b4e8fb63b5f5ad2a584529826e908
✓ Synced with 1 seed(s)
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
   f2de534..f567f69  master -> master
modified crates/radicle-node/src/worker/fetch.rs
@@ -7,15 +7,18 @@ use localtime::LocalTime;

use radicle::cob::TypedId;
use radicle::crypto::PublicKey;
+
use radicle::identity::crefs::GetCanonicalRefs as _;
use radicle::identity::DocAt;
use radicle::prelude::NodeId;
use radicle::prelude::RepoId;
+
use radicle::storage::git::Repository;
use radicle::storage::refs::RefsAt;
use radicle::storage::{
    ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, RepositoryError,
    WriteRepository as _,
};
use radicle::{cob, git, node, Storage};
+
use radicle_fetch::git::refs::Applied;
use radicle_fetch::{Allowed, BlockList, FetchLimit};

use super::channels::ChannelsFlush;
@@ -89,8 +92,6 @@ impl Handle {
        remote: PublicKey,
        refs_at: Option<Vec<RefsAt>>,
    ) -> Result<FetchResult, error::Fetch> {
-
        use git::canonical::QuorumError::{Diverging, NoCandidates};
-

        let (result, clone, notifs) = match self {
            Self::Clone { mut handle, tmp } => {
                log::debug!(target: "worker", "{} cloning from {remote}", handle.local());
@@ -145,15 +146,19 @@ impl Handle {
                            log::trace!(target: "worker", "Set HEAD to {}", head.new);
                        }
                    }
-
                    Err(RepositoryError::Quorum(Diverging(e))) => {
-
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
+
                    Err(RepositoryError::Quorum(radicle::git::canonical::QuorumError::Git(e))) => {
+
                        return Err(e.into())
                    }
-
                    Err(RepositoryError::Quorum(NoCandidates(e))) => {
+
                    Err(RepositoryError::Quorum(e)) => {
                        log::warn!(target: "worker", "Fetch could not set HEAD: {e}")
                    }
                    Err(e) => return Err(e.into()),
                }

+
                if let Err(e) = set_canonical_refs(&repo, &applied) {
+
                    log::warn!(target: "worker", "Failed to set canonical references: {e}");
+
                }
+

                // Notifications are only posted for pulls, not clones.
                if let Some(mut store) = notifs {
                    // Only create notifications for repos that we have
@@ -377,3 +382,71 @@ where

    Ok(())
}
+

+
fn set_canonical_refs(repo: &Repository, applied: &Applied) -> Result<(), error::Canonical> {
+
    let identity = repo.identity()?;
+
    let rules = match identity
+
        .canonical_refs()?
+
        .map(|crefs| crefs.rules().clone())
+
        .filter(|rules| !rules.is_empty())
+
    {
+
        None => return Ok(()),
+
        Some(rules) => rules,
+
    };
+

+
    for update in applied.updated.iter() {
+
        let name = match update {
+
            RefUpdate::Updated { name, .. } | RefUpdate::Created { name, .. } => name,
+
            _ => {
+
                log::trace!(target: "worker", "Skipping update {update}");
+
                continue;
+
            }
+
        };
+
        let Some(name) = name.clone().into_qualified() else {
+
            log::warn!(target: "worker", "Skipping update for canonical reference '{name}' because it is not qualified.");
+
            continue;
+
        };
+
        let Some(name) = name.to_namespaced() else {
+
            log::warn!(target: "worker", "Skipping update for canonical reference '{name}' because it is not namespaced.");
+
            continue;
+
        };
+

+
        let name = name.strip_namespace();
+

+
        let canonical = match rules.canonical(name.clone(), repo) {
+
            Ok(Some(canonical)) => canonical,
+
            Ok(None) => continue,
+
            Err(e) => {
+
                log::warn!(target: "worker", "Failed to get canonical reference rule for {name}: {e}");
+
                continue;
+
            }
+
        };
+

+
        match canonical.quorum(&repo.backend) {
+
            Err(err) => {
+
                log::warn!(
+
                    target: "worker",
+
                    "Failed to calculate canonical reference: {}",
+
                    err,
+
                );
+
                continue;
+
            }
+
            Ok((refname, oid)) => {
+
                if let Err(e) = repo.backend.reference(
+
                    refname.clone().as_str(),
+
                    *oid,
+
                    true,
+
                    "set-canonical-reference from fetch (radicle)",
+
                ) {
+
                    log::warn!(
+
                        target: "worker",
+
                        "Failed to set canonical reference {}->{}: {e}",
+
                        refname,
+
                        oid
+
                    );
+
                }
+
            }
+
        }
+
    }
+
    Ok(())
+
}
modified crates/radicle-node/src/worker/fetch/error.rs
@@ -65,3 +65,11 @@ pub enum Handle {
    #[error(transparent)]
    Repository(#[from] radicle::storage::RepositoryError),
}
+

+
#[derive(Debug, Error)]
+
pub enum Canonical {
+
    #[error(transparent)]
+
    Identity(#[from] radicle::storage::RepositoryError),
+
    #[error(transparent)]
+
    Payload(#[from] radicle::identity::PayloadError),
+
}
modified crates/radicle-remote-helper/src/push.rs
@@ -5,6 +5,8 @@ use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{assert_eq, io};

+
use radicle::identity::crefs::GetCanonicalRefs as _;
+
use radicle::identity::doc::DefaultBranchRuleError;
use radicle::node::device::Device;
use thiserror::Error;

@@ -15,8 +17,7 @@ use radicle::cob::patch::cache::Patches as _;
use radicle::crypto;
use radicle::explorer::ExplorerResource;
use radicle::git::canonical;
-
use radicle::git::canonical::Canonical;
-
use radicle::identity::Did;
+
use radicle::identity::{CanonicalRefs, Did};
use radicle::node;
use radicle::node::{Handle, NodeId};
use radicle::storage;
@@ -116,6 +117,8 @@ pub enum Error {
    /// Quorum error.
    #[error(transparent)]
    Quorum(#[from] radicle::git::canonical::QuorumError),
+
    #[error(transparent)]
+
    DefaultBranchRule(#[from] radicle::identity::doc::DefaultBranchRuleError),
}

/// Push command.
@@ -203,6 +206,10 @@ pub fn run(
        }
    }
    let delegates = stored.delegates()?;
+
    let identity = stored.identity()?;
+
    let project = identity.project()?;
+
    let canonical_ref = git::refs::branch(project.default_branch());
+
    let mut set_canonical_refs: Vec<(git::Qualified, git::Oid)> = Vec::with_capacity(specs.len());

    // For each refspec, push a ref or delete a ref.
    for spec in specs {
@@ -262,8 +269,11 @@ pub fn run(
                        )
                    } else {
                        let identity = stored.identity()?;
-
                        let project = identity.project()?;
-
                        let canonical_ref = git::refs::branch(project.default_branch());
+
                        let crefs = identity.canonical_refs_or_default(|| {
+
                            let rule = identity.doc().default_branch_rule()?;
+
                            Ok::<_, DefaultBranchRuleError>(CanonicalRefs::from_iter([rule]))
+
                        })?;
+
                        let rules = crefs.rules();
                        let me = Did::from(nid);

                        // If we're trying to update the canonical head, make sure
@@ -272,66 +282,51 @@ pub fn run(
                        //
                        // Note that we *do* allow rolling back to a previous commit on the
                        // canonical branch.
-
                        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().as_ref(),
-
                                identity.threshold(),
-
                            )?;
-
                            let converges = canonical::converges(
-
                                canonical
-
                                    .tips()
-
                                    .filter_map(|(did, tip)| (*did != me).then_some(tip)),
-
                                head.into(),
-
                                &working,
-
                            )?;
-
                            if converges {
-
                                canonical.modify_vote(me, head.into());
-
                            }
+
                        if let Some(mut canonical) = rules.canonical(dst.clone(), stored)? {
+
                            if canonical.is_allowed(&me) {
+
                                let head = working.find_reference(src.as_str())?;
+
                                let head = head.peel_to_commit()?.id();
+
                                let converges =
+
                                    canonical.converges(&working, (&me, &head.into()))?;
+

+
                                // If `canonical` is empty then we're creating a new reference.
+
                                // If we're the only delegate then we need to modify our vote.
+
                                if converges || canonical.has_no_tips() || canonical.is_only(&me) {
+
                                    canonical.modify_vote(me, head.into());
+
                                }

-
                            match canonical.quorum(&working) {
-
                                Ok(canonical_oid) => {
-
                                    // Canonical head is an ancestor of head.
-
                                    let is_ff = head == *canonical_oid
-
                                        || working.graph_descendant_of(head, *canonical_oid)?;
-

-
                                    if !is_ff && !converges {
-
                                        if hints {
-
                                            hint(
-
                                                "you are attempting to push a commit that would cause \
-
                                                 your upstream to diverge from the canonical head",
-
                                            );
-
                                            hint(
-
                                                "to integrate the remote changes, run `git pull --rebase` \
-
                                                 and try again",
-
                                            );
+
                                match canonical.quorum(&working) {
+
                                    Ok((dst, canonical_oid)) => {
+
                                        // Canonical head is an ancestor of head.
+
                                        let is_ff = head == *canonical_oid
+
                                            || working.graph_descendant_of(head, *canonical_oid)?;
+

+
                                        if !is_ff && !converges {
+
                                            if hints {
+
                                                hint(
+
                                                    format!("you are attempting to push a commit that would cause \
+
                                                    your upstream to diverge from the canonical reference {dst}"),
+
                                                );
+
                                                hint(
+
                                                    "to integrate the remote changes, run `git pull --rebase` \
+
                                                    and try again",
+
                                                );
+
                                            }
+
                                            return Err(Error::HeadsDiverge(
+
                                                head.into(),
+
                                                canonical_oid,
+
                                            ));
                                        }
-
                                        return Err(Error::HeadsDiverge(
-
                                            head.into(),
-
                                            canonical_oid,
-
                                        ));
+
                                        set_canonical_refs
+
                                            .push((dst.clone().to_owned(), canonical_oid));
+
                                    }
+
                                    Err(canonical::QuorumError::Git(e)) => return Err(e.into()),
+
                                    Err(e) => {
+
                                        warn(e.to_string());
+
                                        warn("it is recommended to find a commit to agree upon");
                                    }
                                }
-
                                Err(canonical::QuorumError::Diverging(e)) => {
-
                                    warn(format!(
-
                                        "could not determine canonical tip for `{canonical_ref}`"
-
                                    ));
-
                                    warn(e.to_string());
-
                                    warn("it is recommended to find a commit to agree upon");
-
                                }
-
                                Err(canonical::QuorumError::NoCandidates(e)) => {
-
                                    warn(format!(
-
                                        "could not determine canonical tip for `{canonical_ref}`"
-
                                    ));
-
                                    warn(e.to_string());
-
                                    warn("it is recommended to find a commit to agree upon");
-
                                }
-
                                Err(e) => return Err(e.into()),
-
                            };
+
                            }
                        }
                        push(src, &dst, *force, &nid, &working, stored, patches, &signer)
                    }
@@ -354,16 +349,50 @@ pub fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

-
        // N.b. if an error occurs then there may be no quorum
-
        if let Ok(head) = stored.set_head() {
-
            if head.is_updated() {
+
        for (refname, oid) in &set_canonical_refs {
+
            let print_update = || {
                eprintln!(
-
                    "{} Canonical head updated to {}",
+
                    "{} Canonical reference {} updated to {}",
                    term::format::positive("✓"),
-
                    term::format::secondary(head.new),
-
                );
+
                    term::format::secondary(refname),
+
                    term::format::secondary(oid),
+
                )
+
            };
+

+
            // N.b. special case for handling the canonical ref, since it
+
            // creates a symlink to HEAD
+
            if *refname == canonical_ref
+
                && stored
+
                    .set_head()
+
                    .map(|head| head.is_updated())
+
                    .unwrap_or(false)
+
            {
+
                print_update();
+
                continue;
            }
-
        };
+

+
            match stored.backend.refname_to_id(refname.as_str()) {
+
                Ok(new) if new != **oid => {
+
                    stored.backend.reference(
+
                        refname.as_str(),
+
                        **oid,
+
                        true,
+
                        "set-canonical-reference from git-push (radicle)",
+
                    )?;
+
                    print_update();
+
                }
+
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
+
                    stored.backend.reference(
+
                        refname.as_str(),
+
                        **oid,
+
                        true,
+
                        "set-canonical-reference from git-push (radicle)",
+
                    )?;
+
                    print_update();
+
                }
+
                _ => {}
+
            }
+
        }

        if !opts.no_sync {
            if profile.policies()?.is_seeding(&stored.id)? {
modified crates/radicle/src/git/canonical.rs
@@ -2,126 +2,64 @@ pub mod rules;
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

use std::collections::BTreeMap;
-
use std::fmt;

-
use nonempty::NonEmpty;
use raw::Repository;
use thiserror::Error;

use crate::prelude::Did;
-
use crate::prelude::Project;
use crate::storage::ReadRepository;

use super::raw;
-
use super::{lit, Oid, Qualified};
+
use super::{Oid, Qualified};

/// A collection of [`Did`]s and their [`Oid`]s that is the tip for a given
/// reference for that [`Did`].
///
-
/// The general construction of `Canonical` is by using the
-
/// [`Canonical::reference`] constructor. For the default branch of a
-
/// [`Project`], use [`Canonical::default_branch`].
+
/// The general construction of `Canonical` is by using the [`Canonical::new`]
+
/// constructor.
///
/// `Canonical` can then be used for performing calculations about the
/// canonicity of the reference, most importantly the [`Canonical::quorum`].
+
///
+
/// References to the refname and the matched rule are kept, as they
+
/// are very handy for generating error messages.
#[derive(Debug)]
-
pub struct Canonical {
+
pub struct Canonical<'a, 'b> {
+
    refname: Qualified<'a>,
+
    rule: &'b ValidRule,
    tips: BTreeMap<Did, Oid>,
-
    threshold: usize,
}

/// Error that can occur when calculation the [`Canonical::quorum`].
#[derive(Debug, Error)]
pub enum QuorumError {
    /// Could not determine a quorum [`Oid`], due to diverging tips.
-
    #[error("could not determine canonical reference tip, {0}")]
-
    Diverging(Diverging),
+
    #[error("could not determine commit for canonical reference '{refname}', found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")]
+
    Diverging {
+
        refname: String,
+
        threshold: usize,
+
        base: Oid,
+
        longest: Oid,
+
        head: Oid,
+
    },
    /// Could not determine a base candidate from the given set of delegates.
-
    #[error("could not determine canonical reference tip, {0}")]
-
    NoCandidates(NoCandidates),
+
    #[error("could not determine commit for canonical reference '{refname}', no commit with at least {threshold} vote(s) found (threshold not met)")]
+
    NoCandidates { refname: String, threshold: usize },
    /// An error occurred from [`git2`].
    #[error(transparent)]
    Git(#[from] git2::Error),
}

-
/// No candidates were found for the [`Canonical::quorum`] calculation.
-
///
-
/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
-
/// the threshold and delegates in the calculation.
-
#[derive(Debug)]
-
pub struct NoCandidates {
-
    threshold: usize,
-
}
-

-
impl fmt::Display for NoCandidates {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        let NoCandidates { threshold } = self;
-
        write!(
-
            f,
-
            "no commit found with at least {threshold} vote(s) (threshold not met)"
-
        )
-
    }
-
}
-

-
/// Diverging commits were found during the [`Canonical::quorum`] calculation.
-
///
-
/// The [`fmt::Display`] is used in [`QuorumError`], to provide information on
-
/// the threshold, base commit, and the two diverging commits, in the
-
/// calculation.
-
#[derive(Debug)]
-
pub struct Diverging {
-
    threshold: usize,
-
    base: Oid,
-
    longest: Oid,
-
    head: Oid,
-
}
-

-
impl fmt::Display for Diverging {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        let Diverging {
-
            threshold,
-
            base,
-
            longest,
-
            head,
-
        } = self;
-
        write!(f, "found diverging commits {longest} and {head}, with base commit {base} and threshold {threshold}")
-
    }
-
}
-

-
impl Canonical {
-
    /// Construct the set of canonical tips of the `Project::default_branch` for
-
    /// the given `delegates`.
-
    pub fn default_branch<S>(
-
        repo: &S,
-
        project: &Project,
-
        delegates: &NonEmpty<Did>,
-
        threshold: usize,
-
    ) -> Result<Self, raw::Error>
-
    where
-
        S: ReadRepository,
-
    {
-
        Self::reference(
-
            repo,
-
            &lit::refs_heads(project.default_branch()).into(),
-
            delegates,
-
            threshold,
-
        )
-
    }
-

-
    /// Construct the set of canonical tips given for the given `delegates` and
-
    /// the reference `name`.
-
    pub fn reference<S>(
-
        repo: &S,
-
        refname: &Qualified,
-
        delegates: &NonEmpty<Did>,
-
        threshold: usize,
-
    ) -> Result<Self, raw::Error>
+
impl<'a, 'b> Canonical<'a, 'b> {
+
    /// Construct the set of canonical tips given for the given `rule` and
+
    /// the reference `refname`.
+
    pub fn new<S>(repo: &S, refname: Qualified<'a>, rule: &'b ValidRule) -> Result<Self, raw::Error>
    where
        S: ReadRepository,
    {
        let mut tips = BTreeMap::new();
-
        for delegate in delegates.iter() {
-
            match repo.reference_oid(delegate, refname) {
+
        for delegate in rule.allowed().iter() {
+
            match repo.reference_oid(delegate, &refname) {
                Ok(tip) => {
                    tips.insert(*delegate, tip);
                }
@@ -135,7 +73,11 @@ impl Canonical {
                Err(e) => return Err(e),
            }
        }
-
        Ok(Canonical { tips, threshold })
+
        Ok(Canonical {
+
            refname,
+
            tips,
+
            rule,
+
        })
    }

    /// Return the set of [`Did`]s and their [`Oid`] tip.
@@ -143,36 +85,18 @@ impl Canonical {
        self.tips.iter()
    }

-
    /// Returns `true` is there were no tips found for any of the delegates for
+
    /// Returns `true` if there were no tips found for any of the DIDs for
    /// the given reference.
    ///
    /// N.b. this may be the case when a new reference is being created.
-
    pub fn is_empty(&self) -> bool {
+
    pub fn has_no_tips(&self) -> bool {
        self.tips.is_empty()
    }
-
}

-
/// Check that a given `target` converges with any of the provided `tips`.
-
///
-
/// It converges if the `target` is either equal to, ahead of, or behind any of
-
/// the tips.
-
pub fn converges<'a>(
-
    tips: impl Iterator<Item = &'a Oid>,
-
    target: Oid,
-
    repo: &Repository,
-
) -> Result<bool, raw::Error> {
-
    for tip in tips {
-
        match repo.graph_ahead_behind(*target, **tip)? {
-
            (0, 0) => return Ok(true),
-
            (ahead, behind) if ahead > 0 && behind == 0 => return Ok(true),
-
            (ahead, behind) if behind > 0 && ahead == 0 => return Ok(true),
-
            (_, _) => {}
-
        }
+
    pub fn refname(&self) -> &Qualified {
+
        &self.refname
    }
-
    Ok(false)
-
}

-
impl Canonical {
    /// In some cases, we allow the vote to be modified. For example, when the
    /// `did` is pushing a new commit, we may want to see if the new commit will
    /// reach a quorum.
@@ -180,6 +104,41 @@ impl Canonical {
        self.tips.insert(did, new);
    }

+
    /// Check that the provided `did` is part of the set of allowed
+
    /// DIDs of the matching rule.
+
    pub fn is_allowed(&self, did: &Did) -> bool {
+
        self.rule.allowed().contains(did)
+
    }
+

+
    /// Check that the provided `did` is the only DID in the set of allowed
+
    /// DIDs of the matching rule.
+
    pub fn is_only(&self, did: &Did) -> bool {
+
        self.rule.allowed().is_only(did)
+
    }
+

+
    /// Checks that setting the given candidate tip would converge with at least
+
    /// one other known tip.
+
    ///
+
    /// It converges if the candidate Oid is either equal to, ahead of, or behind any of
+
    /// the tips.
+
    pub fn converges(
+
        &self,
+
        repo: &Repository,
+
        candidate: (&Did, &Oid),
+
    ) -> Result<bool, raw::Error> {
+
        for tip in self
+
            .tips
+
            .iter()
+
            .filter_map(|(did, tip)| (did != candidate.0).then_some(tip))
+
        {
+
            let (ahead, behind) = repo.graph_ahead_behind(**candidate.1, **tip)?;
+
            if ahead * behind == 0 {
+
                return Ok(true);
+
            }
+
        }
+
        Ok(false)
+
    }
+

    /// Computes the quorum or "canonical" tip based on the tips, of `Canonical`,
    /// and the threshold. This can be described as the latest commit that is
    /// included in at least `threshold` histories. In case there are multiple tips
@@ -187,7 +146,7 @@ impl Canonical {
    ///
    /// Also returns an error if `heads` is empty or `threshold` cannot be
    /// satisified with the number of heads given.
-
    pub fn quorum(self, repo: &raw::Repository) -> Result<Oid, QuorumError> {
+
    pub fn quorum(self, repo: &raw::Repository) -> Result<(Qualified<'a>, Oid), QuorumError> {
        let mut candidates = BTreeMap::<_, usize>::new();

        // Build a list of candidate commits and count how many "votes" each of them has.
@@ -212,14 +171,12 @@ impl Canonical {
            }
        }
        // Keep commits which pass the threshold.
-
        candidates.retain(|_, votes| *votes >= self.threshold);
+
        candidates.retain(|_, votes| *votes >= self.threshold());

-
        let (mut longest, _) =
-
            candidates
-
                .pop_first()
-
                .ok_or(QuorumError::NoCandidates(NoCandidates {
-
                    threshold: self.threshold,
-
                }))?;
+
        let (mut longest, _) = candidates.pop_first().ok_or(QuorumError::NoCandidates {
+
            refname: self.refname.to_string(),
+
            threshold: self.threshold(),
+
        })?;

        // Now that all scores are calculated, figure out what is the longest branch
        // that passes the threshold. In case of divergence, return an error.
@@ -253,15 +210,20 @@ impl Canonical {
                //            o (base)
                //            |
                //
-
                return Err(QuorumError::Diverging(Diverging {
-
                    threshold: self.threshold,
+
                return Err(QuorumError::Diverging {
+
                    refname: self.refname.to_string(),
+
                    threshold: self.threshold(),
                    base: base.into(),
                    longest,
                    head: *head,
-
                }));
+
                });
            }
        }
-
        Ok((*longest).into())
+
        Ok((self.refname, (*longest).into()))
+
    }
+

+
    fn threshold(&self) -> usize {
+
        (*self.rule.threshold()).into()
    }
}

@@ -281,7 +243,7 @@ mod tests {
        threshold: usize,
        repo: &git::raw::Repository,
    ) -> Result<Oid, QuorumError> {
-
        let tips = heads
+
        let tips: BTreeMap<Did, Oid> = heads
            .iter()
            .enumerate()
            .map(|(i, head)| {
@@ -290,7 +252,24 @@ mod tests {
                (did, (*head).into())
            })
            .collect();
-
        Canonical { tips, threshold }.quorum(repo)
+

+
        let refname =
+
            git::refs::branch(git_ext::ref_format::RefStr::try_from_str("master").unwrap());
+

+
        let rule: RawRule = crate::git::canonical::rules::Rule::new(
+
            crate::git::canonical::rules::Allowed::Delegates,
+
            threshold,
+
        );
+
        let delegates = crate::identity::doc::Delegates::new(tips.keys().cloned()).unwrap();
+
        let rule = rule.validate(&mut || delegates.clone()).unwrap();
+

+
        Canonical {
+
            refname,
+
            tips,
+
            rule: &rule,
+
        }
+
        .quorum(repo)
+
        .map(|(_, oid)| oid)
    }

    #[test]
@@ -352,9 +331,6 @@ mod tests {
        assert_eq!(quorum(&[*c0], 1, &repo).unwrap(), c0);
        assert_eq!(quorum(&[*c1], 1, &repo).unwrap(), c1);
        assert_eq!(quorum(&[*c2], 1, &repo).unwrap(), c2);
-
        assert_eq!(quorum(&[*c0], 0, &repo).unwrap(), c0);
-
        assert_matches!(quorum(&[], 0, &repo), Err(QuorumError::NoCandidates(_)));
-
        assert_matches!(quorum(&[*c0], 2, &repo), Err(QuorumError::NoCandidates(_)));

        //  C1
        //  |
@@ -380,23 +356,23 @@ mod tests {
        //  C0
        assert_matches!(
            quorum(&[*c1, *c2, *b2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*b2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*b2, *c2], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_eq!(quorum(&[*c1, *c2, *b2], 2, &repo).unwrap(), c1);
        assert_eq!(quorum(&[*c1, *c2, *b2], 3, &repo).unwrap(), c1);
@@ -404,7 +380,7 @@ mod tests {
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2], 2, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );

        // B2 C2 C3
@@ -415,15 +391,15 @@ mod tests {
        assert_eq!(quorum(&[*b2, *c2, *c2], 2, &repo).unwrap(), c2);
        assert_matches!(
            quorum(&[*b2, *c2, *c2], 3, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*b2, *c2, *b2, *c2], 3, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*c3, *b2, *c2, *b2, *c2, *c3], 3, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );

        //  B2 C2
@@ -433,19 +409,19 @@ mod tests {
        //   C0
        assert_matches!(
            quorum(&[*c2, *b2, *a1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2, *a1], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*c2, *b2, *a1], 3, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*c1, *c2, *b2, *a1], 4, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 2, &repo).unwrap(), c1,);
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 3, &repo).unwrap(), c1,);
@@ -453,23 +429,23 @@ mod tests {
        assert_eq!(quorum(&[*c0, *c1, *c2, *b2, *a1], 4, &repo).unwrap(), c0,);
        assert_matches!(
            quorum(&[*a1, *a1, *c2, *c2, *c1], 2, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*a1, *a1, *c2, *c2, *c1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*a1, *a1, *c2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*b2, *b2, *c2, *c2, *a1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );

        //    M2  M1
@@ -482,27 +458,27 @@ mod tests {
        assert_eq!(quorum(&[*m1], 1, &repo).unwrap(), m1);
        assert_matches!(
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m2, *m1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m2, *c2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m1, *a1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m1, *a1], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_eq!(quorum(&[*m1, *m2, *b2, *c1], 4, &repo).unwrap(), c1);
        assert_eq!(quorum(&[*m1, *m1, *b2], 2, &repo).unwrap(), m1);
@@ -541,11 +517,11 @@ mod tests {
        //      C0
        assert_matches!(
            quorum(&[*m1, *m2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );

        let m3 = fixtures::commit("M3", &[*c2, *c1], &repo);
@@ -557,27 +533,27 @@ mod tests {
        //      C0
        assert_matches!(
            quorum(&[*m1, *m3], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m1, *m3], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m1], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m1], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m2], 1, &repo),
-
            Err(QuorumError::Diverging(_))
+
            Err(QuorumError::Diverging { .. })
        );
        assert_matches!(
            quorum(&[*m3, *m2], 2, &repo),
-
            Err(QuorumError::NoCandidates(_))
+
            Err(QuorumError::NoCandidates { .. })
        );
    }
}
modified crates/radicle/src/git/canonical/rules.rs
@@ -526,16 +526,6 @@ impl MatchedRule<'_> {
    pub fn threshold(&self) -> &doc::Threshold {
        self.rule().threshold()
    }
-

-
    /// Return the [`Canonical`] representation for the matched rule.
-
    pub fn canonical(&self, repo: &Repository) -> Result<Canonical, git::raw::Error> {
-
        Canonical::reference(
-
            repo,
-
            &self.refname,
-
            self.rule.allow.as_ref(),
-
            self.rule.threshold.into(),
-
        )
-
    }
}

/// A set of valid [`Rule`]s, where the set of DIDs and threshold are fully
@@ -577,6 +567,12 @@ impl IntoIterator for Rules {
    }
}

+
impl Extend<(Pattern, ValidRule)> for Rules {
+
    fn extend<T: IntoIterator<Item = (Pattern, ValidRule)>>(&mut self, iter: T) {
+
        self.rules.extend(iter)
+
    }
+
}
+

impl From<Rules> for RawRules {
    fn from(Rules { rules }: Rules) -> Self {
        Self {
@@ -615,18 +611,32 @@ impl Rules {
        Ok(Self { rules: valid })
    }

-
    /// Return the first matching rule for the given `refname`, if there is any.
+
    /// Return the matching rules for the given `refname`.
+
    pub fn matches<'a>(
+
        &self,
+
        refname: &Qualified<'a>,
+
    ) -> impl Iterator<Item = (&Pattern, &ValidRule)> + use<'a, '_> {
+
        let refname_cloned = refname.clone();
+
        self.rules
+
            .iter()
+
            .filter(move |(pattern, _)| pattern.matches(&refname_cloned))
+
    }
+

+
    /// Match given refname, take the most specific rule, and prepare evaluation
+
    /// as [`Canonical`]
    ///
    /// N.b. it will find the first rule that is most specific for the given
    /// `refname`.
-
    pub fn matches<'a>(&self, refname: Qualified<'a>) -> Option<MatchedRule<'a>> {
-
        self.rules
-
            .iter()
-
            .find(|(pattern, _)| pattern.matches(&refname))
-
            .map(|(_, rule)| MatchedRule {
-
                refname,
-
                rule: rule.clone(),
-
            })
+
    pub fn canonical<'a, 'b>(
+
        &'a self,
+
        refname: Qualified<'b>,
+
        repo: &Repository,
+
    ) -> Result<Option<Canonical<'b, 'a>>, git::raw::Error> {
+
        if let Some((_, rule)) = self.matches(&refname).next() {
+
            Ok(Some(Canonical::new(repo, refname, rule)?))
+
        } else {
+
            Ok(None)
+
        }
    }
}

@@ -1179,19 +1189,21 @@ mod tests {
        // candidates tag.
        let stored = storage.repository(rid).unwrap();
        let failing = git::Qualified::from(git::lit::refs_tags(failing_tag));
-
        for (refname, oid) in tags.iter() {
-
            let matched = rules.matches(refname.clone()).unwrap_or_else(|| {
-
                panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
-
            });
-
            let canonical = matched.canonical(&stored).unwrap();
-
            if *refname == failing {
+
        for (refname, oid) in tags.into_iter() {
+
            let canonical = rules
+
                .canonical(refname.clone(), &stored)
+
                .unwrap()
+
                .unwrap_or_else(|| {
+
                    panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
+
                });
+
            if refname == failing {
                assert!(canonical.quorum(&repo).is_err());
            } else {
                assert_eq!(
                    canonical
                        .quorum(&repo)
                        .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
-
                    *oid,
+
                    (refname, oid),
                )
            }
        }
modified crates/radicle/src/identity.rs
@@ -1,8 +1,10 @@
#![warn(clippy::unwrap_used)]
+
pub mod crefs;
pub mod did;
pub mod doc;
pub mod project;

+
pub use crefs::CanonicalRefs;
pub use crypto::PublicKey;
pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};
added crates/radicle/src/identity/crefs.rs
@@ -0,0 +1,124 @@
+
use serde::{Deserialize, Serialize};
+

+
use crate::git::canonical::{
+
    rules::{self, RawRules, Rules, ValidationError},
+
    ValidRule,
+
};
+

+
use super::doc::{Delegates, Payload};
+

+
/// Implemented by any data type or store that can return [`CanonicalRefs`] and
+
/// [`RawCanonicalRefs`].
+
pub trait GetCanonicalRefs {
+
    type Error: std::error::Error + Send + Sync + 'static;
+

+
    /// Retrieve the [`CanonicalRefs`], returning `Some` if they are not
+
    /// present, and `None` if they are missing.
+
    ///
+
    /// [`Self::Error`] is used to return any domain-specific error by the
+
    /// implementing type.
+
    fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error>;
+

+
    /// Retrieve the [`RawCanonicalRefs`], returning `Some` if they are not
+
    /// present, and `None` if they are missing.
+
    ///
+
    /// [`Self::Error`] is used to return any domain-specific error by the
+
    /// implementing type.
+
    fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error>;
+

+
    /// Retrieve the [`CanonicalRefs`], and in the case of `None`, then use the
+
    /// `default` function to return a default set of [`CanonicalRefs`].
+
    fn canonical_refs_or_default<D, E>(&self, default: D) -> Result<CanonicalRefs, E>
+
    where
+
        D: Fn() -> Result<CanonicalRefs, E>,
+
        E: From<Self::Error>,
+
    {
+
        match self.canonical_refs()? {
+
            Some(crefs) => Ok(crefs),
+
            None => Ok(default()?),
+
        }
+
    }
+
}
+

+
/// Configuration for canonical references and their rules.
+
///
+
/// `RawCanonicalRefs` are verified into [`CanonicalRefs`].
+
#[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RawCanonicalRefs {
+
    rules: RawRules,
+
}
+

+
impl RawCanonicalRefs {
+
    /// Construct a new [`RawCanonicalRefs`] from a set of [`RawRules`].
+
    pub fn new(rules: RawRules) -> Self {
+
        Self { rules }
+
    }
+

+
    /// Return the [`RawRules`].
+
    pub fn raw_rules(&self) -> &RawRules {
+
        &self.rules
+
    }
+

+
    /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
+
    pub fn try_into_canonical_refs<R>(
+
        self,
+
        resolve: &mut R,
+
    ) -> Result<CanonicalRefs, ValidationError>
+
    where
+
        R: Fn() -> Delegates,
+
    {
+
        let rules = Rules::from_raw(self.rules, resolve)?;
+
        Ok(CanonicalRefs { rules })
+
    }
+
}
+

+
/// Configuration for canonical references and their [`Rules`].
+
///
+
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
+
/// implementation.
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+
#[serde(rename_all = "camelCase")]
+
pub struct CanonicalRefs {
+
    rules: Rules,
+
}
+

+
impl CanonicalRefs {
+
    /// Construct a new [`CanonicalRefs`] from a set of [`Rules`].
+
    pub fn new(rules: Rules) -> Self {
+
        CanonicalRefs { rules }
+
    }
+

+
    /// Return the [`Rules`].
+
    pub fn rules(&self) -> &Rules {
+
        &self.rules
+
    }
+
}
+

+
impl FromIterator<(rules::Pattern, ValidRule)> for CanonicalRefs {
+
    fn from_iter<T: IntoIterator<Item = (rules::Pattern, ValidRule)>>(iter: T) -> Self {
+
        Self::new(Rules::from_iter(iter))
+
    }
+
}
+

+
impl Extend<(rules::Pattern, ValidRule)> for CanonicalRefs {
+
    fn extend<T: IntoIterator<Item = (rules::Pattern, ValidRule)>>(&mut self, iter: T) {
+
        self.rules.extend(iter)
+
    }
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
#[non_exhaustive]
+
pub enum CanonicalRefsPayloadError {
+
    #[error("could not convert canonical references to JSON: {0}")]
+
    Json(#[source] serde_json::Error),
+
}
+

+
impl TryFrom<CanonicalRefs> for Payload {
+
    type Error = CanonicalRefsPayloadError;
+

+
    fn try_from(crefs: CanonicalRefs) -> Result<Self, Self::Error> {
+
        let value = serde_json::to_value(crefs).map_err(CanonicalRefsPayloadError::Json)?;
+
        Ok(Self::from(value))
+
    }
+
}
modified crates/radicle/src/identity/doc.rs
@@ -19,6 +19,7 @@ use crate::cob::identity;
use crate::crypto;
use crate::crypto::Signature;
use crate::git;
+
use crate::git::canonical::rules;
use crate::identity::{project::Project, Did};
use crate::node::device::Device;
use crate::storage;
@@ -27,6 +28,9 @@ use crate::storage::{ReadRepository, RepositoryError};
pub use crypto::PublicKey;
pub use id::*;

+
use super::crefs::{self, RawCanonicalRefs};
+
use super::CanonicalRefs;
+

/// Path to the identity document in the identity branch.
pub static PATH: LazyLock<&Path> = LazyLock::new(|| Path::new("radicle.json"));
/// Maximum length of a string in the identity document.
@@ -73,6 +77,14 @@ impl DocError {
    }
}

+
#[derive(Debug, Error)]
+
pub enum DefaultBranchRuleError {
+
    #[error("could not create rule due to the reference name being invalid: {0}")]
+
    Pattern(#[from] rules::PatternError),
+
    #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
+
    Payload(#[from] PayloadError),
+
}
+

/// The version number of the identity document.
///
/// It is used to ensure compatibility when parsing identity documents.
@@ -215,10 +227,20 @@ impl PayloadId {
                .expect("PayloadId::project: type name is valid"),
        )
    }
+

+
    pub fn canonical_refs() -> Self {
+
        Self(
+
            // SAFETY: We know this is valid.
+
            TypeName::from_str("xyz.radicle.crefs")
+
                .expect("PayloadId::canonical_refs: type name is valid"),
+
        )
+
    }
}

#[derive(Debug, Error)]
pub enum PayloadError {
+
    #[error(transparent)]
+
    CanonicalRefs(#[from] rules::ValidationError),
    #[error("json: {0}")]
    Json(#[from] serde_json::Error),
    #[error("payload '{0}' not found in identity document")]
@@ -536,6 +558,11 @@ impl Delegates {
        self.0.contains(did)
    }

+
    /// Check if the `did` is the only delegate of the repository.
+
    pub fn is_only(&self, did: &Did) -> bool {
+
        self.0.tail.is_empty() && &self.0.head == did
+
    }
+

    /// Get the number of delegates in the set.
    pub fn len(&self) -> usize {
        self.0.len()
@@ -715,6 +742,19 @@ impl Doc {
        Ok(proj)
    }

+
    pub fn default_branch_rule(
+
        &self,
+
    ) -> Result<(rules::Pattern, rules::ValidRule), DefaultBranchRuleError> {
+
        let proj = self.project()?;
+
        let refname = proj.default_branch();
+
        let pattern = rules::Pattern::try_from(git::refs::branch(refname).to_owned())?;
+
        let rule = rules::Rule::new(
+
            rules::ResolvedDelegates::Delegates(self.delegates.clone()),
+
            self.threshold,
+
        );
+
        Ok((pattern, rule))
+
    }
+

    /// Return the associated [`Visibility`] of this document.
    pub fn visibility(&self) -> &Visibility {
        &self.visibility
@@ -872,6 +912,28 @@ impl Doc {
    }
}

+
impl crefs::GetCanonicalRefs for Doc {
+
    type Error = PayloadError;
+

+
    fn canonical_refs(&self) -> Result<Option<CanonicalRefs>, Self::Error> {
+
        self.raw_canonical_refs().and_then(|raw| {
+
            raw.map(|raw| {
+
                raw.try_into_canonical_refs(&mut || self.delegates.clone())
+
                    .map_err(PayloadError::from)
+
            })
+
            .transpose()
+
        })
+
    }
+

+
    fn raw_canonical_refs(&self) -> Result<Option<RawCanonicalRefs>, Self::Error> {
+
        let value = self.payload.get(&PayloadId::canonical_refs());
+
        let crefs = value
+
            .map(|value| serde_json::from_value((**value).clone()).map_err(PayloadError::from))
+
            .transpose()?;
+
        Ok(crefs)
+
    }
+
}
+

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
modified crates/radicle/src/storage.rs
@@ -18,7 +18,7 @@ use crate::cob;
use crate::collections::RandomMap;
use crate::git::{canonical, ext as git_ext};
use crate::git::{refspec::Refspec, PatternString, Qualified, RefError, RefStr, RefString};
-
use crate::identity::{Did, PayloadError};
+
use crate::identity::{doc, Did, PayloadError};
use crate::identity::{Doc, DocAt, DocError};
use crate::identity::{Identity, RepoId};
use crate::node::device::Device;
@@ -120,6 +120,10 @@ pub enum RepositoryError {
    Quorum(#[from] canonical::QuorumError),
    #[error(transparent)]
    Refs(#[from] refs::Error),
+
    #[error("missing canonical reference rule for default branch")]
+
    MissingBranchRule,
+
    #[error("could not get the default branch rule: {0}")]
+
    DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
}

impl RepositoryError {
modified crates/radicle/src/storage/git.rs
@@ -11,9 +11,9 @@ use std::{fs, io};
use crypto::Verified;
use tempfile::TempDir;

-
use crate::git::canonical::Canonical;
+
use crate::identity::crefs::GetCanonicalRefs as _;
use crate::identity::doc::DocError;
-
use crate::identity::{Doc, DocAt, RepoId};
+
use crate::identity::{CanonicalRefs, Doc, DocAt, RepoId};
use crate::identity::{Identity, Project};
use crate::node::device::Device;
use crate::node::SyncedAt;
@@ -749,13 +749,18 @@ impl ReadRepository for Repository {

    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();
-
        let oid =
-
            Canonical::default_branch(self, &project, doc.delegates().into(), doc.threshold())?
-
                .quorum(raw)?;
-
        Ok((branch_ref, oid))
+
        let refname = git::refs::branch(doc.project()?.default_branch());
+
        let crefs = match doc.canonical_refs()? {
+
            Some(crefs) => crefs,
+
            // Fallback to constructing the default branch via the project
+
            // payload
+
            None => CanonicalRefs::from_iter([doc.default_branch_rule()?]),
+
        };
+
        Ok(crefs
+
            .rules()
+
            .canonical(refname, self)?
+
            .ok_or(RepositoryError::MissingBranchRule)?
+
            .quorum(self.raw())?)
    }

    fn identity_head(&self) -> Result<Oid, RepositoryError> {