Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
Canonical Symbolic References
Open lorenz opened 7 months ago

Extend the payload xyz.radicle.crefs to additionally support creating symbolic references via the member “symbolic”.

20 files changed +1269 -116 547a7537 91e80949
modified CHANGELOG.md
@@ -283,6 +283,8 @@ With the introduction of `clap`, this helped with the introduction of a command
       environment variable `RAD_PASSPHRASE` (lower priority than the
       credential). The identifier of the credential is
       "xyz.radicle.node.passphrase".
+
- Symbolic references can now be handled by canonical references by coding them
+
  in the payload `xyz.radicle.crefs` under the key `symbolic`.

## Fixed Bugs

added crates/radicle-cli/examples/git/git-push-canonical-branch.md
@@ -0,0 +1,90 @@
+
``` ~alice
+
$ rad id update --title "Add canonical branch for releases" --payload xyz.radicle.crefs rules '{ "refs/heads/releases/*": { "threshold": 1, "allow": [ "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk" ] }, "refs/tags/*": { "threshold": 1, "allow": "delegates" }, "refs/tags/qa/*": { "threshold": 1, "allow": "delegates" }}'
+
✓ Identity revision [..] created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add canonical branch for releases                             │
+
│ Revision 37a1aad231100cd206c49aed79e405ea2da9204b                      │
+
│ Blob     bbefd77cfeb456e500ad868c3b4effe1f7f818e2                      │
+
│ Author   did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi      │
+
│ State    active                                                        │
+
│ Quorum   no                                                            │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice (you) │
+
│ ? did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob         │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,26 +1,32 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.crefs": {
+
       "rules": {
+
+        "refs/heads/releases/*": {
+
+          "allow": [
+
+            "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
+          ],
+
+          "threshold": 1
+
+        },
+
         "refs/tags/*": {
+
           "allow": "delegates",
+
-          "threshold": 2
+
+          "threshold": 1
+
         },
+
         "refs/tags/qa/*": {
+
           "allow": "delegates",
+
           "threshold": 1
+
         }
+
       }
+
     },
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
     "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
   ],
+
   "threshold": 1
+
 }
+
```
+

+
``` ~bob
+
$ cd heartwood
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
$ rad id accept 37a1aad231100cd206c49aed79e405ea2da9204b -q
+
```
+

+
Bob immediately pushes a branch that matches the rule, thus populating
+
modifying the canonical namespace.
+

+
``` ~bob
+
$ git checkout -b releases/2
+
$ git commit --allow-empty --message "Release notes for version 2"
+
[releases/2 afec366] Release notes for version 2
+
```
+

+
``` ~bob (stderr)
+
$ git push -u rad releases/2
+
✓ Canonical reference refs/heads/releases/2 updated to target commit afec366785ed3651cdc66975c0fec41866c9ce62
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
 * [new branch]      releases/2 -> releases/2
+
```
+

+
``` ~alice 
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ git ls-remote rad
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	HEAD
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/heads/master
+
afec366785ed3651cdc66975c0fec41866c9ce62	refs/heads/releases/2
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/tags/qa/v2.1
+
ac51a0746a5e8311829bc481202909a1e3acc0c2	refs/tags/v1.0-hotfix
+
89f935f27a16f8ed97915ade4accab8fe48057aa	refs/tags/v2.0
+
```

\ No newline at end of file
added crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md
@@ -0,0 +1,130 @@
+
Bob overhears that the new default name for the default branch is "main",
+
not "master" as it used to be.
+
To make tooling that expect "main" work, without the hassle of having to push
+
to such branch manually, he opts to create a symbolic reference.
+

+
``` ~bob
+
$ cd heartwood
+
$ rad id update --title "Add canonical symbolic ref" --payload xyz.radicle.crefs symbolic '{ "refs/heads/main": "refs/heads/master" }'
+
✓ Identity revision [..] created
+
╭────────────────────────────────────────────────────────────────────────╮
+
│ Title    Add canonical symbolic ref                                    │
+
│ Revision 62e2cb60c6df9ad9908b6697b5d126760a855484                      │
+
│ Blob     b20de7b184673eb0d9227be17640c923d8ef3f3e                      │
+
│ Author   did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk      │
+
│ State    active                                                        │
+
│ Quorum   no                                                            │
+
├────────────────────────────────────────────────────────────────────────┤
+
│ ✓ did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk bob   (you) │
+
│ ? did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice       │
+
╰────────────────────────────────────────────────────────────────────────╯
+

+
@@ -1,32 +1,35 @@
+
 {
+
   "payload": {
+
     "xyz.radicle.crefs": {
+
       "rules": {
+
         "refs/heads/releases/*": {
+
           "allow": [
+
             "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
           ],
+
           "threshold": 1
+
         },
+
         "refs/tags/*": {
+
           "allow": "delegates",
+
           "threshold": 1
+
         },
+
         "refs/tags/qa/*": {
+
           "allow": "delegates",
+
           "threshold": 1
+
         }
+
+      },
+
+      "symbolic": {
+
+        "refs/heads/main": "refs/heads/master"
+
       }
+
     },
+
     "xyz.radicle.project": {
+
       "defaultBranch": "master",
+
       "description": "Radicle Heartwood Protocol & Stack",
+
       "name": "heartwood"
+
     }
+
   },
+
   "delegates": [
+
     "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
     "did:key:z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk"
+
   ],
+
   "threshold": 1
+
 }
+
```
+

+
Alice is happy with the new revision and accepts it.
+

+
``` ~alice
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+
$ rad id accept 62e2cb60c6df9ad9908b6697b5d126760a855484 -q
+
```
+

+
As usual, alice works on "master".
+

+
``` ~alice
+
$ git commit --allow-empty --message "Whew, new feature!"
+
[master 4dc510d] Whew, new feature!
+
```
+

+
And updating the canonical reference for "master" also works as usual.
+

+
``` ~alice (stderr)
+
$ git push rad
+
✓ Canonical reference refs/heads/master updated to target commit 4dc510ddea5fd66499d1d2e996b8a97c8d57be54
+
✓ Synced with 1 seed(s)
+
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
   f2de534..4dc510d  master -> master
+
```
+

+
Then, Alice is curious about the new symbolic reference.
+
She inspects the remote and sees that indeed a new branch named "main" now exists.
+

+
``` ~alice 
+
$ git ls-remote rad
+
4dc510ddea5fd66499d1d2e996b8a97c8d57be54	HEAD
+
4dc510ddea5fd66499d1d2e996b8a97c8d57be54	refs/heads/main
+
4dc510ddea5fd66499d1d2e996b8a97c8d57be54	refs/heads/master
+
afec366785ed3651cdc66975c0fec41866c9ce62	refs/heads/releases/2
+
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354	refs/tags/qa/v2.1
+
ac51a0746a5e8311829bc481202909a1e3acc0c2	refs/tags/v1.0-hotfix
+
89f935f27a16f8ed97915ade4accab8fe48057aa	refs/tags/v2.0
+
```
+

+
Of course, she can also fetch it to her working copy as usual.
+

+
``` ~alice (stderr)
+
$ git fetch rad
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
 * [new branch]      main       -> rad/main
+
 * [new branch]      releases/2 -> rad/releases/2
+
 * [new tag]         qa/v2.1    -> rad/tags/qa/v2.1
+
 * [new tag]         qa/v2.1    -> qa/v2.1
+
```
+

+
Bob fetches Alice's changes.
+

+
``` ~bob
+
$ rad sync -f
+
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential seed(s).
+
✓ Target met: 1 seed(s)
+
🌱 Fetched from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
+
```
+

+
And, sure enough, there is the new branch just as he wanted it.
+

+
``` ~bob (stderr)
+
$ git fetch rad
+
From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji
+
 * [new branch]      main       -> rad/main
+
   f2de534..4dc510d  master     -> rad/master
+
```
+

+
Note that neither Alice nor Bob pushed directly to "main".

\ No newline at end of file
modified crates/radicle-cli/tests/commands/git.rs
@@ -301,8 +301,12 @@ fn git_push_canonical_lightweight_tags() {
    .unwrap();
}

+
/// This test exercises a large surface of the "Canonical References" feature:
+
///  - Annotated Tags
+
///  - Branches
+
///  - Symbolic References
#[test]
-
fn git_push_canonical_annotated_tags() {
+
fn git_push_canonical() {
    let mut environment = Environment::new();
    let alice = environment.node("alice");
    let bob = environment.node("bob");
@@ -341,4 +345,40 @@ fn git_push_canonical_annotated_tags() {
    )
    .run()
    .unwrap();
+

+
    formula(
+
        &environment.tempdir(),
+
        "examples/git/git-push-canonical-branch.md",
+
    )
+
    .unwrap()
+
    .home(
+
        "alice",
+
        environment.work(&alice),
+
        [("RAD_HOME", alice.home.path().display())],
+
    )
+
    .home(
+
        "bob",
+
        environment.work(&bob),
+
        [("RAD_HOME", bob.home.path().display())],
+
    )
+
    .run()
+
    .unwrap();
+

+
    formula(
+
        &environment.tempdir(),
+
        "examples/git/git-push-canonical-symbolic-ref.md",
+
    )
+
    .unwrap()
+
    .home(
+
        "alice",
+
        environment.work(&alice),
+
        [("RAD_HOME", alice.home.path().display())],
+
    )
+
    .home(
+
        "bob",
+
        environment.work(&bob),
+
        [("RAD_HOME", bob.home.path().display())],
+
    )
+
    .run()
+
    .unwrap();
}
modified crates/radicle-node/src/worker/fetch.rs
@@ -14,8 +14,7 @@ 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 _,
+
    ReadRepository, ReadStorage as _, RefUpdate, RemoteRepository, WriteRepository as _,
};
use radicle::{Storage, cob, git, node};
use radicle_fetch::git::refs::Applied;
@@ -128,15 +127,6 @@ impl Handle {
                // points to a repository that is temporary and gets moved by [`mv`].
                let repo = storage.repository(rid)?;
                repo.set_identity_head()?;
-
                match repo.set_head_to_default_branch() {
-
                    Ok(()) => {
-
                        log::trace!(target: "worker", "Set HEAD successfully");
-
                    }
-
                    Err(RepositoryError::Quorum(e)) => {
-
                        log::warn!(target: "worker", "Fetch could not set HEAD for {rid}: {e}")
-
                    }
-
                    Err(e) => return Err(e.into()),
-
                }

                let canonical = match set_canonical_refs(&repo, &applied) {
                    Ok(updates) => updates.unwrap_or_default(),
@@ -346,8 +336,21 @@ fn set_canonical_refs(
    repo: &Repository,
    applied: &Applied,
) -> Result<Option<UpdatedCanonicalRefs>, error::Canonical> {
+
    const LOG_MESSAGE: &str = "set-canonical-reference from fetch (radicle)";
+

    let identity = repo.identity()?;
-
    let rules = identity.doc().canonical_refs()?.rules().clone();
+
    let crefs = identity.doc().canonical_refs()?;
+

+
    for (name, target) in crefs.symbolic().iter() {
+
        if let Err(e) = repo.set_symbolic_ref(name, target, LOG_MESSAGE) {
+
            log::warn!(
+
                target: "worker",
+
                "Failed to set canonical symbolic reference '{name}' → '{target}': {e}"
+
            );
+
        }
+
    }
+

+
    let rules = crefs.rules().clone();

    let mut updated_refs = UpdatedCanonicalRefs::default();
    let refnames = applied
@@ -389,12 +392,10 @@ fn set_canonical_refs(
                refname, object, ..
            }) => {
                let oid = object.id();
-
                if let Err(e) = repo.backend.reference(
-
                    refname.clone().as_str(),
-
                    oid.into(),
-
                    true,
-
                    "set-canonical-reference from fetch (radicle)",
-
                ) {
+
                if let Err(e) =
+
                    repo.backend
+
                        .reference(refname.clone().as_str(), oid.into(), true, LOG_MESSAGE)
+
                {
                    log::warn!(
                        target: "worker",
                        "Failed to set canonical reference {refname}->{oid}: {e}"
modified crates/radicle-remote-helper/src/push.rs
@@ -258,6 +258,8 @@ pub(super) fn run(
    git: &impl GitService,
    node: &mut impl NodeSession,
) -> Result<Vec<String>, Error> {
+
    const LOG_MESSAGE: &str = "set-canonical-reference from git-push (radicle)";
+

    // Don't allow push if either of these conditions is true:
    //
    // 1. Our key is not in ssh-agent, which means we won't be able to sign the refs.
@@ -290,9 +292,6 @@ pub(super) 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::fmt::Qualified, git::canonical::Object)> =
        Vec::with_capacity(specs.len());

@@ -407,6 +406,8 @@ pub(super) fn run(
    if !ok.is_empty() {
        let _ = stored.sign_refs(&signer)?;

+
        stored.set_canonical_symbolic_refs(LOG_MESSAGE)?;
+

        for (refname, object) in &set_canonical_refs {
            let oid = object.id();
            let kind = object.object_type();
@@ -419,20 +420,11 @@ pub(super) fn run(
                )
            };

-
            // N.b. special case for handling the canonical ref, since it
-
            // creates a symlink to HEAD
-
            if *refname == canonical_ref {
-
                stored.set_head_to_default_branch()?;
-
            }
-

            match stored.backend.refname_to_id(refname.as_str()) {
                Ok(new) if oid != new => {
-
                    stored.backend.reference(
-
                        refname.as_str(),
-
                        oid.into(),
-
                        true,
-
                        "set-canonical-reference from git-push (radicle)",
-
                    )?;
+
                    stored
+
                        .backend
+
                        .reference(refname.as_str(), oid.into(), true, LOG_MESSAGE)?;
                    print_update();
                }
                Err(e) if e.code() == git::raw::ErrorCode::NotFound => {
modified crates/radicle/src/git/canonical.rs
@@ -12,6 +12,7 @@ mod voting;
pub mod effects;
pub mod protect;
pub mod rules;
+
pub mod symbolic;

pub use rules::{MatchedRule, RawRule, Rules, ValidRule};

modified crates/radicle/src/git/canonical/protect.rs
@@ -19,7 +19,7 @@ pub enum Error {
}

/// A witnesses that the inner reference-like value is not protected.
-
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
+
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, Hash)]
#[repr(transparent)]
#[serde(transparent)]
pub(super) struct Unprotected<T: RefLike>(T);
@@ -42,6 +42,13 @@ impl<T: RefLike> Unprotected<T> {
    pub fn into_inner(self) -> T {
        self.0
    }
+

+
    /// Allows creation without any checking. Callers must ensure that
+
    /// `reflike` is indeed unprotected!
+
    #[inline]
+
    const fn new_unchecked(reflike: T) -> Self {
+
        Self(reflike)
+
    }
}

impl<T: RefLike> AsRef<T> for Unprotected<T> {
@@ -68,7 +75,10 @@ impl<T: RefLike + std::fmt::Display> std::fmt::Display for Unprotected<T> {
/// For types that are commonly used in conjunction with [`Unprotected`]
/// have some `impl`s and companion infallible injections.
mod impls {
-
    use crate::git::fmt::{RefString, refspec::QualifiedPattern};
+
    use crate::git::{
+
        fmt::{Qualified, RefStr, RefString, refname, refspec::QualifiedPattern},
+
        refs::branch,
+
    };

    use super::*;

@@ -76,6 +86,33 @@ mod impls {
    /// means to be [`RefLike`].
    impl RefLike for RefString {}

+
    impl Unprotected<RefString> {
+
        /// The reference name `HEAD`.
+
        // We would like to have a `pub const HEAD`, but
+
        // [`crate::git::RefStr::from_str`] is private.
+
        #[inline]
+
        pub fn head() -> Self {
+
            // Calling [`Unprotected::new_unchecked`] here is legal,
+
            // because we know statically that `HEAD` is not protected.
+
            Unprotected::new_unchecked(refname!("HEAD"))
+
        }
+
    }
+

+
    /// [`Qualified`] is a restriction on [`RefString`].
+
    impl RefLike for Qualified<'_> {}
+

+
    impl Unprotected<Qualified<'_>> {
+
        /// Construct a qualified reference name for given branch, i.e.,
+
        /// return `/refs/heads/<name>`
+
        pub fn branch(name: &RefStr) -> Self {
+
            Self::new(branch(name)).expect("branches are never protected")
+
        }
+

+
        pub fn to_ref_string(&self) -> Unprotected<RefString> {
+
            Unprotected::new_unchecked(self.0.to_ref_string())
+
        }
+
    }
+

    /// A [`QualifiedPattern`] is [`RefLike`] in the sense that it matches a
    /// (possibly infinite) set of [`crate::git::Qualified`].
    impl RefLike for QualifiedPattern<'_> {}
modified crates/radicle/src/git/canonical/rules.rs
@@ -390,6 +390,22 @@ impl From<Did> for Allowed {
    }
}

+
impl std::fmt::Display for Allowed {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            Allowed::Delegates => f.write_str("\"allowed\""),
+
            Allowed::Set(dids) => {
+
                let dids = dids
+
                    .iter()
+
                    .map(|did| did.to_string())
+
                    .collect::<Vec<_>>()
+
                    .join("\", \"");
+
                f.write_fmt(format_args!("[\"{dids}\"]"))
+
            }
+
        }
+
    }
+
}
+

/// A marker `enum` that is used in a [`ValidRule`].
///
/// It ensures that a rule that has been deserialized, resolving the `delegates`
added crates/radicle/src/git/canonical/symbolic.rs
@@ -0,0 +1,389 @@
+
//! Symbolic references, which link neither to nor from protected references.
+
//! The prototypical example is `HEAD → refs/heads/main`.
+

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

+
use crate::git::fmt::Qualified;
+
use crate::git::fmt::RefString;
+

+
use super::protect::Unprotected;
+

+
use reachability::reachable;
+

+
pub type RawName = RefString;
+

+
/// Names of symbolic references are unprotected references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *from*
+
/// protected references unrepresentable.
+
pub(super) type Name = Unprotected<RefString>;
+

+
impl std::cmp::Ord for Name {
+
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+
        self.as_ref().cmp(other.as_ref())
+
    }
+
}
+

+
impl std::cmp::PartialOrd for Name {
+
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+
        Some(self.cmp(other))
+
    }
+
}
+

+
pub type RawTarget = RefString;
+

+
/// Targets for symbolic references are unprotected references.
+
/// Requiring [`Unprotected`] makes symbolic references that link *to*
+
/// protected references unrepresentable.
+
pub(super) type Target = Unprotected<RefString>;
+

+
/// Maintains a cycle-free set of symbolic references.
+
/// Note that dangling references are not detected.
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(try_from = "IndexMap<Name, Target>")]
+
pub struct SymbolicRefs(IndexMap<Name, Target>);
+

+
/// Read-only access.
+
impl SymbolicRefs {
+
    /// Returns an iterator over all contained symbolic references, as pairs of
+
    /// their name [`RawName`] and [`RawTarget`].
+
    pub fn iter(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+
        self.0
+
            .iter()
+
            .map(|(name, target)| (name.as_ref(), target.as_ref()))
+
    }
+

+
    /// Returns an iterator over all contained symbolic references, as pairs of
+
    /// their name [`RawName`] and resolved [`RawTarget`].
+
    pub fn iter_resolved(&self) -> impl Iterator<Item = (&RawName, &RawTarget)> {
+
        self.iter_resolved_unprotected()
+
            .map(|(name, target)| (name.as_ref(), target.as_ref()))
+
    }
+

+
    pub(super) fn iter_resolved_unprotected(&self) -> impl Iterator<Item = (&Name, &Target)> {
+
        self.0
+
            .keys()
+
            .filter_map(|name| self.resolve_unprotected(name).map(|target| (name, target)))
+
    }
+

+
    fn resolve_unprotected<'a, 'b>(&'a self, mut name: &'b Name) -> Option<&'a Target>
+
    where
+
        'a: 'b,
+
    {
+
        while let Some(target) = self.0.get(name) {
+
            match self.0.get(target) {
+
                Some(next) => {
+
                    name = next;
+
                }
+
                None => return Some(target),
+
            }
+
        }
+

+
        None
+
    }
+

+
    /// Returns `true` if the set of symbolic references is empty.
+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_empty()
+
    }
+
}
+

+
/// Utilities for handling of `HEAD`.
+
impl SymbolicRefs {
+
    /// Construct [`SymbolicRefs`] for the single symbolic reference `HEAD`
+
    /// targeting `/refs/heads/<branch_name>`.
+
    // This exists to encapsulate [`Unprotected`].
+
    pub fn head(branch_name: &RefString) -> Self {
+
        let mut result = Self::default();
+
        result
+
            .try_insert_unprotected(
+
                Unprotected::head().to_owned(),
+
                Unprotected::branch(branch_name).to_ref_string(),
+
            )
+
            .expect("not creating cycle");
+
        result
+
    }
+

+
    /// Convenience method to get the target of the `HEAD` reference.
+
    /// See also [`SymbolicRefs::head`].
+
    pub fn resolve_head(&self) -> Option<&RawTarget> {
+
        self.resolve_unprotected(&Unprotected::head())
+
            .map(|target| target.as_ref())
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum InsertionError {
+
    #[error("inserting symbolic reference '{name} → {target}' would create a cycle")]
+
    Cyclic { name: RawName, target: RawTarget },
+

+
    #[error(
+
        "inserting symbolic reference '{name} → {target}' would result in a symbolic reference targeting an unqualified reference"
+
    )]
+
    TargetNotQualified { name: RawName, target: RawTarget },
+
}
+

+
/// Mutability.
+
impl SymbolicRefs {
+
    /// Insert a symbolic reference.
+
    /// Even though this method will never return [`InsertionError::Protected`]
+
    /// we opt to return that (slightly more general) error, as it allows
+
    /// construction of [`InsertionError::Cyclic`] by consuming `name` and
+
    /// `target`, avoiding an early copy in [`Self::try_insert`].
+
    pub(super) fn try_insert_unprotected(
+
        &mut self,
+
        name: Name,
+
        target: Target,
+
    ) -> Result<(), InsertionError> {
+
        if reachable(&self.0, &target, &name) {
+
            return Err(InsertionError::Cyclic {
+
                name: name.into_inner(),
+
                target: target.into_inner(),
+
            });
+
        }
+

+
        let target_is_qualified = Qualified::from_refstr(target.as_ref()).is_some();
+

+
        if !target_is_qualified {
+
            match self.resolve_unprotected(&target) {
+
                Some(end) => {
+
                    if Qualified::from_refstr(end.as_ref()).is_none() {
+
                        return Err(InsertionError::TargetNotQualified {
+
                            name: name.into_inner(),
+
                            target: target.into_inner(),
+
                        });
+
                    }
+
                }
+
                None => {
+
                    return Err(InsertionError::TargetNotQualified {
+
                        name: name.into_inner(),
+
                        target: target.into_inner(),
+
                    });
+
                }
+
            }
+
        }
+

+
        self.0.insert(name, target);
+
        Ok(())
+
    }
+

+
    /// Try to insert a symbolic reference.
+
    /// Errors if `name` or `target` is protected (see [`protect`]) or would
+
    /// cause infinite recursion (e.g. `A → B` and `B → A`).
+
    ///
+
    /// # Panics
+
    ///
+
    /// If `name` or `target` is not unprotected.
+
    #[cfg(test)]
+
    fn try_insert(&mut self, name: RawName, target: RawTarget) -> Result<(), InsertionError> {
+
        self.try_insert_unprotected(
+
            Unprotected::new(name).expect("name is unprotected"),
+
            Unprotected::new(target).expect("target is unprotected"),
+
        )
+
    }
+

+
    /// Consume `other` by iteratively inserting into self.
+
    pub fn combine(&mut self, other: SymbolicRefs) -> Result<(), InsertionError> {
+
        for (name, target) in other.0 {
+
            self.try_insert_unprotected(name, target)?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
#[error("symbolic reference '{name}' is cyclic")]
+
pub struct Cyclic {
+
    name: RawName,
+
}
+

+
impl TryFrom<IndexMap<Name, Target>> for SymbolicRefs {
+
    type Error = InsertionError;
+

+
    fn try_from(map: IndexMap<Name, Target>) -> Result<Self, Self::Error> {
+
        let mut result = Self::default();
+
        for (name, target) in map.iter() {
+
            result.try_insert_unprotected(name.clone(), target.clone())?;
+
        }
+
        Ok(result)
+
    }
+
}
+

+
mod reachability {
+
    pub(super) trait Get<'a, 'b, K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V>;
+
    }
+

+
    impl<'a, 'b, K: Ord, V> Get<'a, 'b, K, V> for std::collections::BTreeMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            std::collections::BTreeMap::get(self, key)
+
        }
+
    }
+

+
    impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for std::collections::HashMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            std::collections::HashMap::get(self, key)
+
        }
+
    }
+

+
    impl<'a, 'b, K: Eq + std::hash::Hash, V> Get<'a, 'b, K, V> for indexmap::IndexMap<K, V> {
+
        fn get(&'a self, key: &'b K) -> Option<&'a V> {
+
            indexmap::IndexMap::get(self, key)
+
        }
+
    }
+

+
    /// A reachability check linking
+
    /// from `K` to `V` using [`Get`], and
+
    /// from `V` to `K` using [`Into`].
+
    /// Note that the bound is trivial if `K = V`.
+
    ///
+
    /// This can be used to check whether inserting `key → val`
+
    /// would create a cycle.
+
    ///
+
    /// # Returns
+
    ///
+
    /// Whether `key == val` (under [`Into::into`]) or
+
    /// `key` is reachable from `val` (under [`Into::into`] and [`Get::get`]).
+
    pub(super) fn reachable<'a, 'b, K: PartialEq, V: 'a>(
+
        map: &'a impl Get<'a, 'b, K, V>,
+
        val: &'b V,
+
        key: &'b K,
+
    ) -> bool
+
    where
+
        'a: 'b,
+
        &'b V: Into<&'b K>,
+
    {
+
        // Self-Reference
+
        let src = val.into();
+
        if *src == *key {
+
            return true;
+
        }
+

+
        // Chase
+
        let mut src = src;
+
        while let Some(tmp) = map.get(src).map(|value| value.into()) {
+
            if *tmp == *key {
+
                return true;
+
            }
+
            src = tmp;
+
        }
+

+
        false
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use crate::assert_matches;
+
    use crate::git::fmt::refname;
+

+
    use super::*;
+

+
    #[test]
+
    fn infinite_single() {
+
        let mut symbolic = SymbolicRefs::default();
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("a"), refname!("a")),
+
            Err(InsertionError::Cyclic { .. })
+
        );
+

+
        assert!(symbolic.is_empty());
+
    }
+

+
    #[test]
+
    fn infinite_multi() {
+
        let mut symbolic = SymbolicRefs::default();
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("a"), refname!("refs/heads/b")),
+
            Ok(())
+
        );
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("refs/heads/b"), refname!("refs/heads/c")),
+
            Ok(())
+
        );
+

+
        assert_matches!(
+
            symbolic.try_insert(refname!("refs/heads/c"), refname!("a")),
+
            Err(InsertionError::Cyclic { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn deserialize_valid() {
+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/b",
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn deserialize_order() {
+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "MAIN": "refs/heads/master",
+
                "HEAD": "MAIN",
+
            })),
+
            Ok(_)
+
        );
+

+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "HEAD": "MAIN",
+
                "MAIN": "refs/heads/master",
+
            })),
+
            Err(_)
+
        );
+
    }
+

+
    #[test]
+
    fn deserialize_infinite() {
+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/a",
+
            })),
+
            Err(_)
+
        );
+

+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "refs/heads/a": "refs/heads/b",
+
                "refs/heads/b": "refs/heads/c",
+
                "refs/heads/c": "refs/heads/a",
+
            })),
+
            Err(_)
+
        );
+

+
        assert_matches!(
+
            serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
                "HEAD": "b",
+
            })),
+
            Err(_)
+
        );
+
    }
+

+
    /// Motivates why we cannot simply delegate to [`BTreeMap::extend`]
+
    /// for combining [`SymbolicRefs`].
+
    #[test]
+
    fn infinite_extend() {
+
        let mut a = SymbolicRefs::default();
+
        assert_matches!(
+
            a.try_insert(refname!("refs/heads/a"), refname!("refs/heads/b")),
+
            Ok(())
+
        );
+

+
        let mut b = SymbolicRefs::default();
+
        assert_matches!(
+
            b.try_insert(refname!("refs/heads/b"), refname!("refs/heads/a")),
+
            Ok(())
+
        );
+

+
        assert_matches!(a.combine(b), Err(InsertionError::Cyclic { .. }));
+
    }
+
}
modified crates/radicle/src/identity/crefs.rs
@@ -1,22 +1,48 @@
use serde::{Deserialize, Serialize};
+
use thiserror::Error;

-
use crate::git::canonical::rules::{RawRules, Rules, ValidationError};
+
use crate::git::canonical::rules::{self, RawRules, Rules};
+
use crate::git::canonical::symbolic::{self, SymbolicRefs};
+
use crate::git::fmt::Qualified;

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

+
#[derive(Debug, Error)]
+
pub enum ValidationError {
+
    #[error(transparent)]
+
    Rules(#[from] rules::ValidationError),
+

+
    #[error("the target of the symbolic reference '{name} → {target}' is not matched by any rule")]
+
    Dangling {
+
        name: symbolic::RawName,
+
        target: symbolic::RawTarget,
+
    },
+

+
    #[error(
+
        "the symbolic reference name '{name}' is also matched by rule(s) with pattern(s) {patterns:?}"
+
    )]
+
    Clash {
+
        patterns: Vec<String>,
+
        name: Qualified<'static>,
+
    },
+
}
+

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

+
    #[serde(default)] // Default to empty for backwards compatibility.
+
    symbolic: SymbolicRefs,
}

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

    /// Return the [`RawRules`].
@@ -24,6 +50,21 @@ impl RawCanonicalRefs {
        &self.rules
    }

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

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

+
    /// Return the [`SymbolicRefs`] for mutation.
+
    pub fn symbolic_mut(&mut self) -> &mut SymbolicRefs {
+
        &mut self.symbolic
+
    }
+

    /// Validate the [`RawCanonicalRefs`] into a set of [`CanonicalRefs`].
    pub fn try_into_canonical_refs<R>(
        self,
@@ -33,7 +74,7 @@ impl RawCanonicalRefs {
        R: Fn() -> Delegates,
    {
        let rules = Rules::from_raw(self.rules, resolve)?;
-
        Ok(CanonicalRefs::new(rules))
+
        CanonicalRefs::new(rules, self.symbolic)
    }
}

@@ -41,22 +82,65 @@ impl RawCanonicalRefs {
///
/// [`CanonicalRefs`] can be converted into a [`Payload`] using its [`From`]
/// implementation.
-
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalRefs {
    rules: Rules,
+

+
    #[serde(default, skip_serializing_if = "SymbolicRefs::is_empty")]
+
    symbolic: SymbolicRefs,
}

impl CanonicalRefs {
-
    /// Construct a new [`CanonicalRefs`] from a set of [`Rules`].
-
    pub fn new(rules: Rules) -> Self {
-
        CanonicalRefs { rules }
+
    /// Construct a new [`CanonicalRefs`] from a set of [`Rules`] and
+
    /// [`SymbolicRefs`], validating that these may be evaluated to a well
+
    /// formed set of references when interpreted together.
+
    pub fn new(rules: Rules, symbolic: SymbolicRefs) -> Result<Self, ValidationError> {
+
        for (name, target) in symbolic.iter_resolved() {
+
            if Qualified::from_refstr(target)
+
                .and_then(|qualified| rules.matches(&qualified).next())
+
                .is_none()
+
            {
+
                return Err(ValidationError::Dangling {
+
                    name: name.to_owned(),
+
                    target: target.to_owned(),
+
                });
+
            }
+

+
            let Some(name) = Qualified::from_refstr(name) else {
+
                continue;
+
            };
+

+
            let patterns = rules
+
                .matches(&name)
+
                .map(|(pattern, _)| pattern.to_string())
+
                .collect::<Vec<_>>();
+
            if !patterns.is_empty() {
+
                return Err(ValidationError::Clash {
+
                    patterns,
+
                    name: name.to_owned(),
+
                });
+
            }
+
        }
+

+
        Ok(CanonicalRefs { rules, symbolic })
    }

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

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

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

#[derive(Debug, thiserror::Error)]
@@ -74,3 +158,120 @@ impl TryFrom<CanonicalRefs> for Payload {
        Ok(Self::from(value))
    }
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod tests {
+
    use serde_json::json;
+

+
    use crate::assert_matches;
+

+
    use super::{ValidationError::*, *};
+

+
    fn from(value: serde_json::Value) -> Result<CanonicalRefs, super::ValidationError> {
+
        let delegates: Delegates = crate::test::arbitrary::r#gen::<crate::prelude::Did>(1).into();
+
        serde_json::from_value::<RawCanonicalRefs>(value)
+
            .unwrap()
+
            .try_into_canonical_refs(&mut || delegates.clone())
+
    }
+

+
    /// Backwards compatibility to before addition of symbolic references.
+
    #[test]
+
    fn omit_symbolic() {
+
        assert_matches!(
+
            from(json!({
+
                "rules": {},
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_dangling() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "HEAD": "refs/heads/master"
+
                },
+
                "rules": {},
+
            })),
+
            Err(Dangling { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_clash() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/foo": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                    "refs/heads/bar": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Err(Clash { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn invalid_clash_asterisk_name() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Err(Clash { .. })
+
        );
+
    }
+

+
    #[test]
+
    fn valid_asterisk_target() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "HEAD": "refs/heads/master",
+
                },
+
                "rules": {
+
                    "refs/heads/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Ok(_)
+
        );
+
    }
+

+
    #[test]
+
    fn valid() {
+
        assert_matches!(
+
            from(json!({
+
                "symbolic": {
+
                    "refs/heads/foo": "refs/heads/ruled/bar",
+
                },
+
                "rules": {
+
                    "refs/heads/ruled/*": {
+
                        "allow": "delegates",
+
                        "threshold": 1,
+
                    },
+
                },
+
            })),
+
            Ok(_)
+
        );
+
    }
+
}
modified crates/radicle/src/identity/doc.rs
@@ -20,7 +20,11 @@ use crate::crypto;
use crate::crypto::Signature;
use crate::git;
use crate::git::canonical::rules;
+
use crate::git::canonical::symbolic;
+
use crate::git::fmt::Qualified;
+
use crate::git::fmt::RefString;
use crate::git::raw::ErrorExt as _;
+
use crate::identity::crefs;
use crate::identity::{Did, project::Project};
use crate::node::device::Device;
use crate::storage;
@@ -75,9 +79,25 @@ impl DocError {
}

#[derive(Debug, Error)]
-
pub enum DefaultBranchRuleError {
-
    #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
-
    Payload(#[from] PayloadError),
+
pub enum DefaultBranchError {
+
    // #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
+
    // Payload(#[from] PayloadError),
+
    #[error(
+
        "could not find default branch in any of the payloads `xyz.radicle.project` ({project}) or `xyz.radicle.crefs` ({crefs})"
+
    )]
+
    Payload {
+
        project: PayloadError,
+
        crefs: PayloadError,
+
    },
+

+
    #[error("no symbolic reference with name `HEAD` is defined")]
+
    MissingHead,
+

+
    #[error(transparent)]
+
    CanonicalRefsError(#[from] CanonicalRefsError),
+

+
    #[error("the target of the symbolic reference `HEAD` is not qualified: {0}")]
+
    UnqualifiedHead(git::fmt::RefString),
}

/// The version number of the identity document.
@@ -757,42 +777,137 @@ impl Doc {
    }

    /// Gets the qualified reference name of the default branch,
-
    /// according to the project payload in this document.
-
    pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, PayloadError> {
-
        Ok(git::refs::branch(self.project()?.default_branch()))
-
    }
-

-
    pub fn default_branch_rule(&self) -> Result<rules::Rules, DefaultBranchRuleError> {
-
        let pattern = git::fmt::refspec::QualifiedPattern::from(git::refs::branch(
-
            self.project()?.default_branch(),
-
        ));
-
        let rule = rules::Rule::new(
-
            rules::ResolvedDelegates::Delegates(self.delegates.clone()),
-
            self.threshold,
-
        );
-
        Ok(rules::Rules::from_raw(
-
            rules::RawRules::from_iter([(pattern, rule.into())]),
-
            &mut || self.delegates.clone(),
-
        )
-
        .expect("default rules are valid"))
+
    /// according to payloads `xyz.radicle.project` and `xyz.radicle.crefs`
+
    /// in this document.
+
    pub fn default_branch(&self) -> Result<git::fmt::Qualified<'_>, DefaultBranchError> {
+
        let crefs = self.canonical_refs()?;
+
        let refname = crefs
+
            .symbolic()
+
            .resolve_head()
+
            .ok_or(DefaultBranchError::MissingHead)?;
+
        let qualified = refname
+
            .qualified()
+
            .ok_or(DefaultBranchError::UnqualifiedHead(refname.clone()))?;
+
        Ok(qualified.to_owned())
    }

    /// Construct the canonical references for this document.
    /// The implementation of [`RawCanonicalRefs`] is used to
    /// obtain the payload identified by [`PayloadId::canonical_refs`], if it
    /// exists.
-
    /// The resulting [`CanonicalRefs`] are constructed by extension with
-
    /// [`Self::default_branch_rule`].
    ///
    /// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
+
    ///
+
    /// Starts by obtaining the payload identified by
+
    /// [`PayloadId::canonical_refs`].
+
    ///
+
    /// If the payload exists, and it contains both a symbolic reference with
+
    /// the name `HEAD` and a rule matching the corresponding target branch,
+
    /// then this rule is verified to be backwards compatible, i.e. that the
+
    /// value for `allowed` is [`rules::Allowed::Delegates`] and the threshold
+
    /// matches [`Self::threshold`]. If additionally a payload identified by
+
    /// [`PayloadId::project`] exists and can be loaded, then the target
+
    /// branch of the symbolic reference with name `HEAD` is verified to match
+
    /// [`Project::default_branch_qualified`] as well.
+
    ///
+
    /// If the payload is missing, canonical references are synthesized from
+
    /// the payload identified by [`PayloadId::project`]:
+
    /// - A rule exactly matching [`Project::default_branch`]
+
    ///   that is compatible with self.
+
    /// - A symbolic reference with name `HEAD`
+
    ///   (see [`symbolic::SymbolicRefs::head`]) that targets the same branch.
+
    ///
+
    /// The resulting [`CanonicalRefs`] must pass validation, and there are
+
    /// cases where the payload is valid as such, but invalid in combination
+
    /// with the synthesized rule and symbolic reference. For example, if
+
    /// there is a symbolic reference already, with the name of the default
+
    /// branch, this will clash with the synthesized rule.
    pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
+
        let project = self.project();
+

        let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();

-
        let mut raw_rules = raw_crefs.raw_rules().clone();
-
        raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?));
+
        let resolve = &mut || self.delegates.clone();
+

+
        // If there is a symbolic reference with name `HEAD` in the crefs
+
        // payload, we do not need to access the project payload to obtain
+
        // the name of the default branch from there.
+
        // However, we must still ensure that the crefs payload, in particular
+
        // the rule matching the target branch of the symbolic reference with
+
        // name `HEAD`, is backwards compatible with the rest of the identity
+
        // document.
+
        // These conditions may only be relaxed by introducing a new version of
+
        // the identity document.
+
        if let Some(default_branch) = raw_crefs.symbolic().resolve_head() {
+
            if let Ok(project) = &project {
+
                let project = project.default_branch_qualified().to_ref_string();
+
                if project != *default_branch {
+
                    return Err(CanonicalRefsError::DefaultBranchRuleError(
+
                        DefaultBranchRuleError::HeadMismatch {
+
                            cref: default_branch.clone(),
+
                            project,
+
                        },
+
                    ));
+
                }
+
            }

-
        let raw_crefs = RawCanonicalRefs::new(raw_rules);
-
        Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
+
            if let Some(default_branch) = Qualified::from_refstr(default_branch)
+
                && let Some((pattern, rule)) = raw_crefs.raw_rules().matches(&default_branch).next()
+
            {
+
                if *rule.allowed() != rules::Allowed::Delegates {
+
                    return Err(CanonicalRefsError::DefaultBranchRuleError(
+
                        DefaultBranchRuleError::Allowed {
+
                            pattern: pattern.to_string(),
+
                            actual: rule.allowed().to_string(),
+
                        },
+
                    ));
+
                }
+
                if *rule.threshold() != self.threshold() {
+
                    return Err(CanonicalRefsError::DefaultBranchRuleError(
+
                        DefaultBranchRuleError::Threshold {
+
                            pattern: pattern.to_string(),
+
                            actual: *rule.threshold(),
+
                            expected: self.threshold(),
+
                        },
+
                    ));
+
                }
+
                // There is a symbolic reference for `HEAD`, but no matching
+
                // canonical reference rule. `HEAD` is dangling!
+
                // `raw_crefs` is malformed and will not pass validation below.
+
            }
+
            return Ok(raw_crefs.try_into_canonical_refs(resolve)?);
+
        }
+

+
        // Since there is no symbolic reference with name `HEAD`, fall back
+
        // to the project payload for obtaining the default branch.
+
        let project = project.map_err(CanonicalRefsError::SynthesisPayloadMissing)?;
+

+
        // Only now, once it is known that synthesis will be required,
+
        // and have a project to do so, make `raw_crefs` mutable.
+
        let mut raw_crefs = raw_crefs;
+

+
        let default_branch = project.default_branch_qualified();
+

+
        if raw_crefs
+
            .raw_rules()
+
            .matches(&default_branch)
+
            .next()
+
            .is_none()
+
        {
+
            let rule = rules::Rule::new(rules::Allowed::Delegates, self.threshold());
+

+
            raw_crefs.raw_rules_mut().insert(
+
                git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()),
+
                rule,
+
            );
+
        }
+

+
        raw_crefs
+
            .symbolic_mut()
+
            .combine(symbolic::SymbolicRefs::head(project.default_branch()))
+
            .map_err(|source| CanonicalRefsError::SynthesisCycle { source })?;
+

+
        Ok(raw_crefs.try_into_canonical_refs(resolve)?)
    }

    /// Return the associated [`Visibility`] of this document.
@@ -953,19 +1068,49 @@ impl Doc {
}

#[derive(Debug, Error)]
+
pub enum RawCanonicalRefsError {
+
    #[error(transparent)]
+
    Json(#[from] serde_json::Error),
+
}
+

+
#[derive(Debug, Error)]
pub enum CanonicalRefsError {
    #[error(transparent)]
    Raw(#[from] RawCanonicalRefsError),
+

    #[error(transparent)]
-
    CanonicalRefs(#[from] rules::ValidationError),
+
    CanonicalRefs(#[from] crefs::ValidationError),
+

+
    #[error("could not load `xyz.radicle.project` to get default branch name: {0}")]
+
    SynthesisPayloadMissing(PayloadError),
+

    #[error(transparent)]
-
    DefaultBranch(#[from] DefaultBranchRuleError),
+
    DefaultBranchRuleError(#[from] DefaultBranchRuleError),
+

+
    #[error("synthesizing canonical references from project payload failed: {source}")]
+
    SynthesisCycle { source: symbolic::InsertionError },
}

#[derive(Debug, Error)]
-
pub enum RawCanonicalRefsError {
-
    #[error(transparent)]
-
    Json(#[from] serde_json::Error),
+
pub enum DefaultBranchRuleError {
+
    #[error(
+
        "rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use 'allow' value of \"delegates\" but uses {actual}"
+
    )]
+
    Allowed { pattern: String, actual: String },
+

+
    #[error(
+
        "rule for pattern '{pattern}' which matches the target of symbolic reference 'HEAD' (possibly synthesized from payload 'xyz.radicle.project') must use a threshold of {expected} as required by the identity document but uses {actual}"
+
    )]
+
    Threshold {
+
        pattern: String,
+
        actual: usize,
+
        expected: usize,
+
    },
+

+
    #[error(
+
        "target symbolic reference 'HEAD' ('{cref}') does not match `defaultBranch` from payload `xyz.radicle.project` ('{project}')"
+
    )]
+
    HeadMismatch { cref: RefString, project: RefString },
}

pub trait GetRawCanonicalRefs: GetPayload {
@@ -1232,4 +1377,72 @@ mod test {
            serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
        );
    }
+

+
    #[test]
+
    fn default_branch_without_project() {
+
        let value = serde_json::json!(
+
            {
+
                "payload": {
+
                    "xyz.radicle.crefs": {
+
                        "symbolic": {
+
                            "HEAD": "refs/heads/main",
+
                        },
+
                        "rules": {
+
                            "refs/heads/main": {
+
                                "allow": "delegates",
+
                                "threshold": 1,
+
                            }
+
                        }
+
                    }
+
                },
+
                "delegates": [
+
                    "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
+
                ],
+
                "threshold": 1
+
            }
+
        );
+

+
        let doc = serde_json::from_value::<Doc>(value).unwrap();
+
        assert_eq!(doc.default_branch().unwrap().as_str(), "refs/heads/main");
+
    }
+

+
    #[test]
+
    fn default_branch_clash() {
+
        let value = serde_json::json!(
+
            {
+
                "payload": {
+
                    "xyz.radicle.project": {
+
                        "name": "example",
+
                        "description": "An example project",
+
                        "defaultBranch": "main",
+
                    },
+
                    "xyz.radicle.crefs": {
+
                        "symbolic": {
+
                            "HEAD": "refs/heads/master",
+
                        },
+
                        "rules": {
+
                            "refs/heads/master": {
+
                                "allow": "delegates",
+
                                "threshold": 1,
+
                            }
+
                        }
+
                    }
+
                },
+
                "delegates": [
+
                    "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM"
+
                ],
+
                "threshold": 1
+
            }
+
        );
+

+
        let doc = serde_json::from_value::<Doc>(value).unwrap();
+
        assert_matches!(
+
            doc.default_branch(),
+
            Err(DefaultBranchError::CanonicalRefsError(
+
                CanonicalRefsError::DefaultBranchRuleError(
+
                    DefaultBranchRuleError::HeadMismatch { .. }
+
                )
+
            ))
+
        );
+
    }
}
modified crates/radicle/src/identity/doc/update.rs
@@ -208,9 +208,13 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
            });
        }
    };
-
    // Ensure that if we have canonical reference rules and a project, that no
-
    // rule exists for the default branch. This rule must be synthesized when
-
    // constructing the canonical reference rules.
+

+
    // If we have both payloads `xyz.radicle.{project,crefs}` ensure that,
+
    // in the `crefs` payload there is no …
+
    //  1. … rule that matches the default branch from the  `project` payload.
+
    //     (This rule must be synthesized!)
+
    //  2. … symbolic reference with the name `HEAD`.
+
    //     (This reference must be synthesized!)
    use super::GetRawCanonicalRefs as _;
    match raw
        .raw_canonical_refs()
@@ -225,11 +229,19 @@ pub fn verify(raw: RawDoc) -> Result<Doc, error::DocVerification> {
                .map(|(pattern, _)| pattern.to_string())
                .collect::<Vec<_>>();
            if !matches.is_empty() {
-
                return Err(error::DocVerification::DisallowDefault { matches, default });
+
                return Err(error::DocVerification::DisallowDefaultBranchRule { matches, default });
+
            }
+

+
            if let Some(symbolic) = crefs.symbolic().resolve_head() {
+
                return Err(error::DocVerification::DisallowDefaultBranchSymbolic {
+
                    symbolic: symbolic.to_owned(),
+
                    default,
+
                });
            }
        }
        _ => { /* we validate below */ }
    }
+

    // Verify that the canonical references payload is valid
    if let Err(e) = proposal.canonical_refs() {
        return Err(error::DocVerification::PayloadError {
@@ -332,7 +344,7 @@ mod test {
        assert!(
            matches!(
                super::verify(raw),
-
                Err(error::DocVerification::DisallowDefault { .. })
+
                Err(error::DocVerification::DisallowDefaultBranchRule { .. })
            ),
            "Verification should be rejected for including default branch rule"
        )
modified crates/radicle/src/identity/doc/update/error.rs
@@ -31,10 +31,17 @@ pub enum DocVerification {
    #[error(
        "incompatible payloads: The rule(s) xyz.radicle.crefs.rules.{matches:?} matches the value of xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Change the name of the default branch or remove the rule(s)."
    )]
-
    DisallowDefault {
+
    DisallowDefaultBranchRule {
        matches: Vec<String>,
        default: git::fmt::Qualified<'static>,
    },
+
    #[error(
+
        "incompatible payloads: The symbolic reference xyz.radicle.crefs.symbolic.HEAD → '{symbolic}' conflicts with xyz.radicle.project.defaultBranch ('{default}'). Possible resolutions: Remove either of the two."
+
    )]
+
    DisallowDefaultBranchSymbolic {
+
        symbolic: RefString,
+
        default: git::fmt::Qualified<'static>,
+
    },
}

#[derive(Clone, Debug)]
modified crates/radicle/src/identity/project.rs
@@ -8,6 +8,7 @@ use thiserror::Error;

use crate::crypto;
use crate::git::BranchName;
+
use crate::git::{fmt::Qualified, refs::branch};
use crate::identity::doc;
use crate::identity::doc::Payload;

@@ -254,6 +255,12 @@ impl Project {
    pub fn default_branch(&self) -> &BranchName {
        &self.default_branch
    }
+

+
    /// Return the qualified name of the default branch.
+
    #[inline]
+
    pub fn default_branch_qualified(&self) -> Qualified<'_> {
+
        branch(&self.default_branch)
+
    }
}

impl From<Project> for Payload {
modified crates/radicle/src/rad.rs
@@ -143,7 +143,7 @@ where
    )?;
    stored.set_remote_identity_root_to(pk, identity)?;
    stored.set_identity_head_to(identity)?;
-
    stored.set_head_to_default_branch()?;
+
    stored.set_canonical_symbolic_refs("set-canonical from init (radicle)")?;
    stored.set_default_branch_to_canonical_head()?;

    let signed = stored.sign_refs(signer)?;
modified crates/radicle/src/storage.rs
@@ -150,7 +150,7 @@ pub enum RepositoryError {
    #[error("missing canonical reference rule for default branch")]
    MissingBranchRule,
    #[error("could not get the default branch rule: {0}")]
-
    DefaultBranchRule(#[from] doc::DefaultBranchRuleError),
+
    DefaultBranchRule(#[from] doc::DefaultBranchError),
    #[error("failed to get canonical reference rules: {0}")]
    CanonicalRefs(#[from] doc::CanonicalRefsError),
    #[error(transparent)]
@@ -668,11 +668,27 @@ where

/// Allows read-write access to a repository.
pub trait WriteRepository: ReadRepository + SignRepository {
-
    /// Sets the symbolic reference `HEAD` to target the default branch.
-
    /// This only depends on the value for the default branch in the identity
-
    /// document, and does not require the canonical reference behind the
-
    /// default branch to be computed, or even exist.
-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError>;
+
    /// Sets the canonical symbolic references.
+
    ///
+
    /// This only depends on canonical references (thus the `xyz.radicle.crefs`
+
    /// payload, and possibly the `xyz.radicle.project` payload in the identity
+
    /// document). The targeted canonical references are not computed and might
+
    /// not even exist.
+
    fn set_canonical_symbolic_refs(&self, message: &str) -> Result<(), RepositoryError> {
+
        for (name, target) in self.identity_doc()?.canonical_refs()?.symbolic().iter() {
+
            self.set_symbolic_ref(name, target, message)?;
+
        }
+
        Ok(())
+
    }
+

+
    /// Sets a symbolic reference, if it does not exist or its target is different
+
    /// from the given one.
+
    fn set_symbolic_ref(
+
        &self,
+
        name: &RefStr,
+
        target: &RefStr,
+
        message: &str,
+
    ) -> Result<(), RepositoryError>;

    /// Computes the head of the default branch based on the delegate set,
    /// and sets it.
modified crates/radicle/src/storage/git.rs
@@ -28,7 +28,7 @@ use crate::{git, git::Oid, node};
use crate::git::RefError;
use crate::git::UserInfo;
use crate::git::fmt::{
-
    Qualified, RefString, refname, refspec, refspec::PatternStr, refspec::PatternString,
+
    Qualified, RefStr, RefString, refspec, refspec::PatternStr, refspec::PatternString,
};
pub use crate::storage::{Error, RepositoryError};

@@ -852,20 +852,17 @@ impl ReadRepository for Repository {

    fn canonical_head(&self) -> Result<(Qualified<'_>, Oid), RepositoryError> {
        let doc = self.identity_doc()?;
-
        let refname = doc.default_branch()?.to_owned();
-

-
        let crefs = doc.canonical_refs()?;
-

-
        Ok(crefs
+
        Ok(doc
+
            .canonical_refs()?
            .rules()
-
            .canonical(refname, self)
+
            .canonical(doc.default_branch()?, self)
            .ok_or(RepositoryError::MissingBranchRule)?
            .find_objects()?
            .quorum()?)
        .map(
            |Quorum {
                 refname, object, ..
-
             }| (refname, object.id()),
+
             }| (refname.to_owned(), object.id()),
        )
    }

@@ -939,31 +936,28 @@ impl ReadRepository for Repository {
}

impl WriteRepository for Repository {
-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
-
        let head_ref = refname!("HEAD");
-
        let branch_ref = self.default_branch()?;
-

-
        match self.raw().find_reference(head_ref.as_str()) {
+
    fn set_symbolic_ref(
+
        &self,
+
        name: &RefStr,
+
        target: &RefStr,
+
        message: &str,
+
    ) -> Result<(), RepositoryError> {
+
        match self.raw().find_reference(name.as_str()) {
            Ok(mut head_ref) => {
                if head_ref
                    .symbolic_target()
-
                    .is_some_and(|t| t != branch_ref.as_str())
+
                    .is_some_and(|t| t != target.as_str())
                {
-
                    head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?;
+
                    head_ref.symbolic_set_target(target.as_str(), message)?;
                }
-
                Ok(())
            }
            Err(err) if err.is_not_found() => {
-
                self.raw().reference_symbolic(
-
                    head_ref.as_str(),
-
                    branch_ref.as_str(),
-
                    true,
-
                    "set-head (radicle)",
-
                )?;
-
                Ok(())
+
                self.raw()
+
                    .reference_symbolic(name.as_str(), target.as_str(), true, message)?;
            }
-
            Err(err) => Err(err.into()),
+
            Err(err) => return Err(err.into()),
        }
+
        Ok(())
    }

    fn set_default_branch_to_canonical_head(&self) -> Result<SetHead, RepositoryError> {
modified crates/radicle/src/test.rs
@@ -59,7 +59,7 @@ pub fn fetch<W: WriteRepository>(
    drop(opts);

    repo.set_identity_head()?;
-
    repo.set_head_to_default_branch()?;
+
    repo.set_canonical_symbolic_refs("set-canonical test (radicle)")?;
    repo.set_default_branch_to_canonical_head()?;

    let validations = repo.validate()?;
modified crates/radicle/src/test/storage.rs
@@ -344,7 +344,12 @@ impl WriteRepository for MockRepository {
        todo!()
    }

-
    fn set_head_to_default_branch(&self) -> Result<(), RepositoryError> {
+
    fn set_symbolic_ref(
+
        &self,
+
        _name: &fmt::RefStr,
+
        _target: &fmt::RefStr,
+
        _message: &str,
+
    ) -> Result<(), RepositoryError> {
        todo!()
    }