Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle/crefs: Support Symbolic References
✓ CI success Lorenz Leutgeb committed 1 day ago
commit 3f81e83d3624dda5ab162cc1ce1a107a7b9960bf
parent 080790d84eb64504406e8f4d309e9049bdfa89ad
1 passed (1 total) View logs
20 files changed +1665 -125
modified CHANGELOG.md
@@ -326,6 +326,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 and
+
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
+
```
added crates/radicle-cli/examples/git/git-push-canonical-symbolic-ref.md
@@ -0,0 +1,130 @@
+
Bob overhears that the new 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".
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> {
@@ -50,6 +57,20 @@ impl<T: RefLike> AsRef<T> for Unprotected<T> {
    }
}

+
impl<T: RefLike> std::borrow::Borrow<T> for Unprotected<T> {
+
    fn borrow(&self) -> &T {
+
        &self.0
+
    }
+
}
+

+
/// Enables looking up entries in a map keyed by `Unprotected<RefString>` using
+
/// a `&RefStr`.
+
impl std::borrow::Borrow<crate::git::fmt::RefStr> for Unprotected<crate::git::fmt::RefString> {
+
    fn borrow(&self) -> &crate::git::fmt::RefStr {
+
        self.0.as_ref()
+
    }
+
}
+

impl<'de, T: RefLike + serde::Deserialize<'de>> serde::Deserialize<'de> for Unprotected<T> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
@@ -68,7 +89,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 +100,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
@@ -386,6 +386,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("\"delegates\""),
+
            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,754 @@
+
//! 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::RefStr;
+
use crate::git::fmt::RefString;
+

+
use super::protect::Unprotected;
+

+
/// A type alias for a [`RefString`] that has yet to be validated into a
+
/// a symbolic reference name.
+
pub type RawName = RefString;
+

+
/// A type alias for a [`RefString`] that has yet to be validated into a
+
/// symbolic reference target.
+
pub type RawTarget = RefString;
+

+
/// The target of a symbolic reference.
+
///
+
/// A target is either:
+
/// - [`Direct`](Target::Direct): a concrete qualified reference
+
///   (e.g. `refs/heads/main`).
+
/// - [`Symbolic`](Target::Symbolic): another symbolic reference name
+
///   (e.g. `MAIN`) that must itself resolve through the chain.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub enum Target {
+
    /// A concrete qualified reference — the end of a chain.
+
    Direct(Direct),
+
    /// Another symbolic reference name — an intermediate link.
+
    Symbolic(Symbolic),
+
}
+

+
impl AsRef<RefStr> for Target {
+
    fn as_ref(&self) -> &RefStr {
+
        match self {
+
            Target::Direct(direct) => direct.0.as_ref(),
+
            Target::Symbolic(symbolic) => symbolic.0.as_ref(),
+
        }
+
    }
+
}
+

+
/// A concrete qualified reference — the end of a chain.
+
// `Unprotected` has `super` visibility, so `Direct` is used to allow `Target`
+
// to be `pub`.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Direct(Unprotected<Qualified<'static>>);
+

+
impl Direct {
+
    fn to_ref_string(&self) -> Unprotected<RefString> {
+
        self.0.to_ref_string()
+
    }
+
}
+

+
impl PartialEq<RefString> for Direct {
+
    fn eq(&self, other: &RefString) -> bool {
+
        **self.0.as_ref() == **other
+
    }
+
}
+

+
impl AsRef<Qualified<'static>> for Direct {
+
    fn as_ref(&self) -> &Qualified<'static> {
+
        self.0.as_ref()
+
    }
+
}
+

+
/// A concrete qualified reference — the end of a chain.
+
// `Unprotected` has `super` visibility, so `Symbolic` is used to allow `Target`
+
// to be `pub`.
+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Symbolic(Unprotected<RefString>);
+

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

+
impl Target {
+
    /// Returns the underlying reference as a `&RefStr`.
+
    pub fn as_refstr(&self) -> &RefStr {
+
        match self {
+
            Target::Direct(q) => q.as_ref().as_ref(),
+
            Target::Symbolic(s) => s.as_ref().as_ref(),
+
        }
+
    }
+

+
    fn direct(d: Unprotected<Qualified<'static>>) -> Self {
+
        Self::Direct(Direct(d))
+
    }
+

+
    fn symbolic(s: Unprotected<RefString>) -> Self {
+
        Self::Symbolic(Symbolic(s))
+
    }
+

+
    /// Classify an [`Unprotected<RefString>`] as either
+
    /// [`Direct`](Target::Direct) or [`Symbolic`](Target::Symbolic)
+
    /// based on whether it is [`Qualified`].
+
    ///
+
    /// The [`Unprotected`] proof is preserved in the resulting variant.
+
    fn classify(s: Unprotected<RefString>) -> Self {
+
        match Qualified::from_refstr(s.as_ref()) {
+
            // Safety: the Qualified is derived from an Unprotected string,
+
            // so it is also unprotected.
+
            Some(q) => Target::direct(
+
                Unprotected::new(q.to_owned())
+
                    .expect("qualified derived from unprotected is unprotected"),
+
            ),
+
            None => Target::symbolic(s),
+
        }
+
    }
+
}
+

+
impl Serialize for Target {
+
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+
        serializer.serialize_str(self.as_refstr().as_str())
+
    }
+
}
+

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

+
/// 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))
+
    }
+
}
+

+
/// Maintains an acyclic set of symbolic references.
+
/// Note that dangling references are not detected.
+
///
+
/// Internally, targets are stored as [`Target`], which distinguishes
+
/// direct (qualified) targets from symbolic (intermediate) ones. This
+
/// means resolution and cycle-checking can pattern-match on the variant
+
/// rather than re-parsing the string.
+
///
+
/// # Deserialization Order
+
///
+
/// Deserialization validates entries in iteration order via
+
/// [`TryFrom<IndexMap>`]. This means deserialization only succeeds if
+
/// symbolic reference whose target is another symbolic
+
/// reference appears *after* that target in the JSON.
+
/// For example,
+
/// `{"MAIN": "refs/heads/master", "HEAD": "MAIN"}` is valid, but
+
/// `{"HEAD": "MAIN", "MAIN": "refs/heads/master"}` is not.
+
///
+
/// To ensure that serialization and deserialization are inverses,
+
/// we must ensure that insertion order and iteration order are the same.
+
/// We do this by using [`IndexMap`] internally, which preserves this property,
+
/// and enabling the feature `preserve_order` of the `serde_json` crate.
+
///
+
/// Normally, JSON objects are unordered (see [RFC 8259, Sec. 4]).
+
/// Any compatible (de-)serializer must preserve key order.
+
///
+
/// [RFC 8259, Sec. 4]: <https://datatracker.ietf.org/doc/html/rfc8259#section-4>
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
+
#[serde(try_from = "IndexMap<Name, Unprotected<RefString>>")]
+
pub struct SymbolicRefs(IndexMap<Name, Target>);
+

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

+
    /// Returns an iterator over all contained symbolic references that
+
    /// resolve to a direct (qualified) target. The yielded target is the
+
    /// final [`Qualified`] reference after chasing through any intermediate
+
    /// symbolic references.
+
    pub fn iter_resolved(&self) -> impl Iterator<Item = (&RawName, &Qualified<'static>)> {
+
        self.0.keys().filter_map(|name| {
+
            self.resolve(name.as_ref())
+
                .map(|target| (name.as_ref(), target))
+
        })
+
    }
+

+
    /// Resolve a name through the chain of symbolic references until a
+
    /// [`Target::Direct`] target is reached. Returns `None` if the
+
    /// name is not in the map or if the chain dangles (ends at a
+
    /// [`Target::Symbolic`] whose name is not a key).
+
    fn resolve(&self, name: &RefString) -> Option<&Qualified<'static>> {
+
        let mut current = self.0.get(name)?;
+
        loop {
+
            match current {
+
                Target::Direct(q) => return Some(q.as_ref()),
+
                Target::Symbolic(s) => match self.0.get(s.as_ref()) {
+
                    Some(next) => current = next,
+
                    None => return 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 [`super::protect::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 resolved target of the `HEAD` reference.
+
    /// Returns the final [`Qualified`] reference after chasing the chain.
+
    /// See also [`SymbolicRefs::head`].
+
    pub fn resolve_head(&self) -> Option<&Qualified<'static>> {
+
        self.resolve(Unprotected::head().as_ref())
+
    }
+
}
+

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

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

+
/// Mutability.
+
impl SymbolicRefs {
+
    /// Insert a symbolic reference from the given `name` to the given `target`.
+
    ///
+
    /// Internally, this classifies the `target` into the [`Target`] and
+
    /// delegates the insertion.
+
    pub(super) fn try_insert_unprotected(
+
        &mut self,
+
        name: Name,
+
        target: Unprotected<RefString>,
+
    ) -> Result<(), InsertionError> {
+
        self.insert(name, Target::classify(target))
+
    }
+

+
    /// Check whether `name` is reachable from `start` by chasing through
+
    /// the map. Used to detect cycles before insertion.
+
    ///
+
    /// Unlike [`resolve`](SymbolicRefs::resolve), this chases through
+
    /// *all* entries regardless of whether the target is [`Direct`](Target::Direct)
+
    /// or [`Symbolic`](Target::Symbolic), since a qualified ref string can
+
    /// also be a key in the map and thus part of a cycle.
+
    fn is_reachable_from(&self, start: &RefString, name: &Name) -> bool {
+
        let name = name.as_ref();
+
        if start == name {
+
            return true;
+
        }
+
        let mut current: &RefStr = start;
+
        loop {
+
            match self.0.get(current) {
+
                None => return false,
+
                Some(target) => {
+
                    let next = target.as_refstr();
+
                    if *next == **name {
+
                        return true;
+
                    }
+
                    current = next;
+
                }
+
            }
+
        }
+
    }
+

+
    /// Try to insert a symbolic reference.
+
    /// Errors if `name` or `target` is protected (see [`super::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.insert(name, target)?;
+
        }
+
        Ok(())
+
    }
+

+
    /// Insert a symbolic reference from the given `name` to the given `target`.
+
    ///
+
    /// The targets in the map can change their classification from
+
    /// [`Target::Direct`] to [`Target::Symbolic`], if the new insertion of the
+
    /// `name` or `target` matches an existing key or existing entries.
+
    ///
+
    /// # Errors
+
    ///
+
    /// The `target` reference must either:
+
    /// - be a direct [`Qualified`] reference, or
+
    /// - resolve to a direct [`Qualified`] reference, if it is a keyed entry in [`SymbolicRefs`].
+
    ///
+
    /// If neither of these is satisfied then an
+
    /// [`InsertionError::TargetNotQualified`] error is returned.
+
    ///
+
    /// If the `name` and `target` end up in a cycle, e.g., `a → b → a`, then an
+
    /// [`InsertionError::Cyclic`] error is returned.
+
    fn insert(&mut self, name: Name, mut target: Target) -> Result<(), InsertionError> {
+
        let target_str = match &target {
+
            Target::Direct(q) => q.as_ref().to_ref_string(),
+
            Target::Symbolic(s) => s.as_ref().clone(),
+
        };
+

+
        if self.is_reachable_from(&target_str, &name) {
+
            return Err(InsertionError::Cyclic {
+
                name: name.into_inner(),
+
                target: target_str,
+
            });
+
        }
+

+
        // A [`Target::Direct`] whose string is already a key is actually
+
        // an intermediate link — downgrade to [`Target::Symbolic`].
+
        if let Target::Direct(q) = &target {
+
            if self.0.contains_key(&q.as_ref().to_ref_string()) {
+
                target = Target::symbolic(q.to_ref_string());
+
            }
+
        }
+

+
        if let Target::Symbolic(s) = &target {
+
            if self.resolve(s.as_ref()).is_none() {
+
                return Err(InsertionError::TargetNotQualified {
+
                    name: name.into_inner(),
+
                    target: target_str,
+
                });
+
            }
+
        }
+

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

+
    /// When a new key is inserted, any existing [`Target::Direct`] whose
+
    /// qualified string matches the new key is no longer terminal — it is
+
    /// now an intermediate link and must be reclassified as [`Target::Symbolic`].
+
    fn reclassify_targets(&mut self, new_key: &Name) {
+
        let key = new_key.as_ref();
+
        for existing in self.0.values_mut() {
+
            if let Target::Direct(q) = existing {
+
                if q == key {
+
                    *existing = Target::symbolic(q.to_ref_string());
+
                }
+
            }
+
        }
+
    }
+
}
+

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

+
    fn try_from(map: IndexMap<Name, Unprotected<RefString>>) -> Result<Self, Self::Error> {
+
        map.into_iter()
+
            .try_fold(Self::default(), |mut result, (name, target)| {
+
                result.try_insert_unprotected(name, target)?;
+
                Ok(result)
+
            })
+
    }
+
}
+

+
#[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(_)
+
        );
+
    }
+

+
    /// Verifies that resolution works correctly for chains with 2 links
+
    /// (even-length), e.g. `HEAD → MAIN → refs/heads/master`.
+
    #[test]
+
    fn resolve_two_hop_chain() {
+
        let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
            "MAIN": "refs/heads/master",
+
            "HEAD": "MAIN",
+
        }))
+
        .unwrap();
+

+
        // HEAD → MAIN → refs/heads/master should resolve to refs/heads/master
+
        assert_eq!(
+
            symrefs.resolve_head().map(|r| r.as_str()),
+
            Some("refs/heads/master"),
+
        );
+
    }
+

+
    /// 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 { .. }));
+
    }
+

+
    /// Verifies that direct targets are stored as [`Target::Direct`].
+
    #[test]
+
    fn target_classification() {
+
        let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
            "HEAD": "refs/heads/main",
+
        }))
+
        .unwrap();
+

+
        let (_, target) = symrefs.iter().next().unwrap();
+
        assert_matches!(target, Target::Direct(_));
+
    }
+

+
    /// Verifies that symbolic targets are stored as [`Target::Symbolic`].
+
    #[test]
+
    fn target_classification_symbolic() {
+
        let symrefs = serde_json::from_value::<SymbolicRefs>(serde_json::json!({
+
            "MAIN": "refs/heads/master",
+
            "HEAD": "MAIN",
+
        }))
+
        .unwrap();
+

+
        let head_entry = symrefs
+
            .iter()
+
            .find_map(|(name, target)| (name.as_str() == "HEAD").then_some(target))
+
            .unwrap();
+
        assert_matches!(head_entry, Target::Symbolic(_));
+

+
        let main_entry = symrefs
+
            .iter()
+
            .find_map(|(name, target)| (name.as_str() == "MAIN").then_some(target))
+
            .unwrap();
+
        assert_matches!(main_entry, Target::Direct(_));
+
    }
+

+
    /// Verifies that an existing direct target can become a symbolic target
+
    /// during a new insertion.
+
    #[test]
+
    fn target_reclassification() {
+
        let mut symrefs = SymbolicRefs::default();
+
        symrefs
+
            .try_insert(refname!("HEAD"), refname!("refs/heads/main"))
+
            .unwrap();
+
        symrefs
+
            .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
            .unwrap();
+
        let main = symrefs
+
            .iter()
+
            .find_map(|(_, target)| {
+
                (target.as_refstr().as_str() == "refs/heads/main").then_some(target)
+
            })
+
            .unwrap();
+
        assert_matches!(main, Target::Symbolic(_));
+
    }
+

+
    /// Verifies that an existing direct target can become a symbolic target
+
    /// during a new insertion.
+
    #[test]
+
    fn target_reclassification_commutative() {
+
        let mut symrefs = SymbolicRefs::default();
+
        symrefs
+
            .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
            .unwrap();
+
        symrefs
+
            .try_insert(refname!("HEAD"), refname!("refs/heads/main"))
+
            .unwrap();
+
        let main = symrefs
+
            .iter()
+
            .find_map(|(_, target)| {
+
                (target.as_refstr().as_str() == "refs/heads/main").then_some(target)
+
            })
+
            .unwrap();
+
        assert_matches!(main, Target::Symbolic(_));
+
    }
+

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

+
        // Build the chain in reverse: terminal first, origin last.
+
        for (name, target) in [
+
            (refname!("refs/heads/c"), refname!("refs/heads/d")),
+
            (refname!("refs/heads/b"), refname!("refs/heads/c")),
+
            (refname!("refs/heads/a"), refname!("refs/heads/b")),
+
        ] {
+
            symrefs.try_insert(name, target).unwrap();
+
        }
+

+
        // Only refs/heads/d (the terminal) should be Direct.
+
        // refs/heads/b and refs/heads/c are both keys AND targets — Symbolic.
+
        for (_, target) in symrefs.iter() {
+
            match target.as_refstr().as_str() {
+
                "refs/heads/d" => assert_matches!(target, Target::Direct(_)),
+
                other => {
+
                    assert_matches!(target, Target::Symbolic(_), "expected Symbolic for {other}")
+
                }
+
            }
+
        }
+

+
        // Resolution should still work through the full chain.
+
        assert_eq!(
+
            symrefs
+
                .resolve(&refname!("refs/heads/a"))
+
                .map(|q| q.as_str()),
+
            Some("refs/heads/d"),
+
        );
+
    }
+

+
    #[test]
+
    fn reclassification_diamond() {
+
        let mut symrefs = SymbolicRefs::default();
+
        symrefs
+
            .try_insert(refname!("HEAD"), refname!("refs/heads/main"))
+
            .unwrap();
+
        symrefs
+
            .try_insert(refname!("DEFAULT"), refname!("refs/heads/main"))
+
            .unwrap();
+

+
        // Both targets are Direct — refs/heads/main is not a key yet.
+
        // Now make it a key:
+
        symrefs
+
            .try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
            .unwrap();
+

+
        // Both HEAD and DEFAULT's targets should now be Symbolic.
+
        let targets_for_main: Vec<_> = symrefs
+
            .iter()
+
            .filter(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
+
            .collect();
+
        assert_eq!(targets_for_main.len(), 2);
+
        for (name, target) in targets_for_main {
+
            assert_matches!(
+
                target,
+
                Target::Symbolic(_),
+
                "expected Symbolic for {name}'s target"
+
            );
+
        }
+
    }
+

+
    #[test]
+
    fn reclassification_order_invariant() {
+
        // Order A: HEAD first, then the chain link.
+
        let a = {
+
            let mut s = SymbolicRefs::default();
+
            s.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
+
                .unwrap();
+
            s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
                .unwrap();
+
            s
+
        };
+

+
        // Order B: chain link first, then HEAD.
+
        let b = {
+
            let mut s = SymbolicRefs::default();
+
            s.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
                .unwrap();
+
            s.try_insert(refname!("HEAD"), refname!("refs/heads/main"))
+
                .unwrap();
+
            s
+
        };
+

+
        // Both should resolve HEAD to the same place.
+
        assert_eq!(a.resolve_head(), b.resolve_head());
+

+
        // Both should have the same classification for the refs/heads/main target.
+
        let classify_a = a
+
            .iter()
+
            .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
+
            .unwrap()
+
            .1;
+
        let classify_b = b
+
            .iter()
+
            .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
+
            .unwrap()
+
            .1;
+
        assert_matches!(classify_a, Target::Symbolic(_));
+
        assert_matches!(classify_b, Target::Symbolic(_));
+
    }
+

+
    #[test]
+
    fn reclassification_combine() {
+
        // A has HEAD → refs/heads/main (Direct)
+
        let mut a = SymbolicRefs::head(&refname!("main"));
+

+
        // B has refs/heads/main → refs/heads/master (Direct)
+
        let mut b = SymbolicRefs::default();
+
        b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
            .unwrap();
+

+
        a.combine(b).unwrap();
+

+
        // After combine, HEAD's target refs/heads/main should be Symbolic.
+
        let main_target = a
+
            .iter()
+
            .find(|(_, t)| t.as_refstr().as_str() == "refs/heads/main")
+
            .unwrap()
+
            .1;
+
        assert_matches!(main_target, Target::Symbolic(_));
+
        assert_eq!(
+
            a.resolve_head().map(|q| q.as_str()),
+
            Some("refs/heads/master")
+
        );
+
    }
+

+
    #[test]
+
    fn reclassification_combine_reverse() {
+
        // B has refs/heads/main → refs/heads/master (Direct)
+
        let mut b = SymbolicRefs::default();
+
        b.try_insert(refname!("refs/heads/main"), refname!("refs/heads/master"))
+
            .unwrap();
+

+
        // A has HEAD → refs/heads/main (Direct)
+
        let a = SymbolicRefs::head(&refname!("main"));
+

+
        b.combine(a).unwrap();
+

+
        // HEAD's target refs/heads/main IS a key — should be Symbolic.
+
        let main_target = b
+
            .iter()
+
            .find_map(|(_, t)| (t.as_refstr().as_str() == "refs/heads/main").then_some(t))
+
            .unwrap();
+
        assert_matches!(main_target, Target::Symbolic(_));
+
        assert_eq!(
+
            b.resolve_head().map(|q| q.as_str()),
+
            Some("refs/heads/master")
+
        );
+
    }
+
}
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: Qualified<'static>,
+
    },
+

+
    #[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,62 @@ 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 rules.matches(target).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 mut patterns = rules
+
                .matches(&name)
+
                .map(|(pattern, _)| pattern.to_string())
+
                .peekable();
+
            if patterns.peek().is_some() {
+
                return Err(ValidationError::Clash {
+
                    patterns: patterns.collect(),
+
                    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 +155,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,20 @@ 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 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),
}

/// The version number of the identity document.
@@ -757,42 +772,133 @@ 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 qualified = crefs
+
            .symbolic()
+
            .resolve_head()
+
            .ok_or(DefaultBranchError::MissingHead)?;
+
        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`].
+
    ///
+
    /// Uses the `xyz.radicle.crefs` payload (if present) and the
+
    /// `xyz.radicle.project` payload to determine the `HEAD` symbolic
+
    /// reference and its associated rule.
+
    ///
+
    /// There are three cases, depending on whether `HEAD` is already
+
    /// defined in the crefs payload and whether a project payload exists:
+
    ///
+
    /// 1. **Explicit HEAD + project**: `HEAD` must agree with
+
    ///    [`Project::default_branch_qualified`], and the matching rule must
+
    ///    use `delegates` with the document's threshold.
+
    /// 2. **Explicit HEAD, no project**: The matching rule must use
+
    ///    `delegates` with the document's threshold.
+
    /// 3. **No HEAD**: A rule and `HEAD` symbolic reference are synthesized
+
    ///    from the project payload (which must exist).
+
    ///
+
    /// In all cases the result must pass [`RawCanonicalRefs::try_into_canonical_refs`]
+
    /// validation. If a rule for `HEAD`'s target is missing, it will be
+
    /// caught as a dangling reference there.
    ///
    /// [`RawCanonicalRefs`]: super::crefs::RawCanonicalRefs
    pub fn canonical_refs(&self) -> Result<CanonicalRefs, CanonicalRefsError> {
-
        let raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
+
        let mut raw_crefs = self.raw_canonical_refs()?.unwrap_or_default();
+
        let resolve = &mut || self.delegates.clone();
+

+
        // Determine where `HEAD` comes from. The `resolve_head()` result
+
        // borrows `raw_crefs`, so clone to allow mutation in the synthesis
+
        // path.
+
        let head: Option<Qualified<'static>> = raw_crefs.symbolic().resolve_head().cloned();
+

+
        match (head, self.project()) {
+
            (Some(ref default_branch), Ok(project)) => {
+
                let project_branch = project.default_branch_qualified();
+
                if project_branch != *default_branch {
+
                    return Err(DefaultBranchRuleError::HeadMismatch {
+
                        cref: default_branch.to_ref_string(),
+
                        project: project_branch.to_ref_string(),
+
                    })?;
+
                }
+
                self.validate_head_rule(&raw_crefs, default_branch)?;
+
            }
+
            (Some(ref default_branch), Err(_)) => {
+
                self.validate_head_rule(&raw_crefs, default_branch)?;
+
            }
+
            (None, Ok(project)) => {
+
                self.synthesize_head(&mut raw_crefs, &project)?;
+
            }
+
            (None, Err(err)) => {
+
                return Err(CanonicalRefsError::SynthesisPayloadMissing(err));
+
            }
+
        }
+

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

+
    /// Validate that the rule matching `HEAD`'s target branch uses
+
    /// `delegates` and the document's threshold.
+
    ///
+
    /// If no rule matches the target, `HEAD` will dangle — this is caught
+
    /// later by [`RawCanonicalRefs::try_into_canonical_refs`] validation.
+
    fn validate_head_rule(
+
        &self,
+
        raw_crefs: &RawCanonicalRefs,
+
        default_branch: &Qualified,
+
    ) -> Result<(), CanonicalRefsError> {
+
        let Some((pattern, rule)) = raw_crefs.raw_rules().matches(default_branch).next() else {
+
            return Ok(());
+
        };

-
        let mut raw_rules = raw_crefs.raw_rules().clone();
-
        raw_rules.extend(rules::RawRules::from(self.default_branch_rule()?));
+
        let allowed = rule.allowed();
+
        if *allowed != rules::Allowed::Delegates {
+
            return Err(DefaultBranchRuleError::Allowed {
+
                pattern: pattern.to_string(),
+
                actual: allowed.to_string(),
+
            })?;
+
        }
+
        let actual = *rule.threshold();
+
        let expected = self.threshold();
+
        if actual != expected {
+
            return Err(DefaultBranchRuleError::Threshold {
+
                pattern: pattern.to_string(),
+
                actual,
+
                expected,
+
            })?;
+
        }
+
        Ok(())
+
    }

-
        let raw_crefs = RawCanonicalRefs::new(raw_rules);
-
        Ok(raw_crefs.try_into_canonical_refs(&mut || self.delegates.clone())?)
+
    /// Synthesize a `HEAD` symbolic reference and a default branch rule
+
    /// from the project payload.
+
    fn synthesize_head(
+
        &self,
+
        raw_crefs: &mut RawCanonicalRefs,
+
        project: &Project,
+
    ) -> Result<(), CanonicalRefsError> {
+
        let default_branch = project.default_branch_qualified();
+

+
        if raw_crefs
+
            .raw_rules()
+
            .matches(&default_branch)
+
            .next()
+
            .is_none()
+
        {
+
            raw_crefs.raw_rules_mut().insert(
+
                git::fmt::refspec::QualifiedPattern::from(default_branch.to_owned()),
+
                rules::Rule::new(rules::Allowed::Delegates, self.threshold()),
+
            );
+
        }
+

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

+
        Ok(())
    }

    /// Return the associated [`Visibility`] of this document.
@@ -953,19 +1059,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 +1368,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_ref_string(),
+
                    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
@@ -142,7 +142,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,30 @@ 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<Name, Target>(
+
        &self,
+
        name: &Name,
+
        target: &Target,
+
        message: &str,
+
    ) -> Result<(), RepositoryError>
+
    where
+
        Name: AsRef<RefStr>,
+
        Target: AsRef<RefStr>;

    /// 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,41 @@ 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()) {
-
            Ok(mut head_ref) => {
-
                if head_ref
-
                    .symbolic_target()
-
                    .is_some_and(|t| t != branch_ref.as_str())
-
                {
-
                    head_ref.symbolic_set_target(branch_ref.as_str(), "set-head (radicle)")?;
+
    fn set_symbolic_ref<Name, Target>(
+
        &self,
+
        name: &Name,
+
        target: &Target,
+
        message: &str,
+
    ) -> Result<(), RepositoryError>
+
    where
+
        Name: AsRef<RefStr>,
+
        Target: AsRef<RefStr>,
+
    {
+
        let name = name.as_ref();
+
        let target = target.as_ref();
+
        match self.raw().find_reference(name.as_str()) {
+
            Ok(mut existing) => match existing.symbolic_target() {
+
                Some(current) if current == target.as_str() => {
+
                    // Already points to the correct target, nothing to do.
                }
-
                Ok(())
-
            }
+
                Some(_) => {
+
                    // Symbolic ref pointing to a different target, update it.
+
                    existing.symbolic_set_target(target.as_str(), message)?;
+
                }
+
                None => {
+
                    // A direct (non-symbolic) ref exists where we expect a
+
                    // symbolic one. Overwrite it with force.
+
                    self.raw()
+
                        .reference_symbolic(name.as_str(), target.as_str(), true, message)?;
+
                }
+
            },
            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,16 @@ impl WriteRepository for MockRepository {
        todo!()
    }

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