Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
radicle: canonical reference rules
Fintan Halpenny committed 9 months ago
commit af6cf03acda43bc28ac7de854ec9a4768a06c397
parent fb8681f5bc5460a7970f023d61b272d0a1474711
5 files changed +1223 -0
modified Cargo.lock
@@ -846,6 +846,12 @@ dependencies = [
]

[[package]]
+
name = "fast-glob"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3afcf4effa2c44390b9912544582d5af29e10dc4c816c5dbebf748e1c7416faa"
+

+
[[package]]
name = "faster-hex"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2464,6 +2470,7 @@ dependencies = [
 "crossbeam-channel",
 "cyphernet",
 "emojis",
+
 "fast-glob",
 "fastrand",
 "git2",
 "jsonschema",
modified crates/radicle/Cargo.toml
@@ -22,6 +22,7 @@ chrono = { workspace = true, features = ["clock"], optional = true }
colored = { workspace = true, optional = true }
crossbeam-channel = { workspace = true }
cyphernet = { workspace = true, features = ["tor", "dns", "p2p-ed25519"] }
+
fast-glob = { version = "0.3.2" }
fastrand = { workspace = true }
git2 = { workspace = true, features = ["vendored-libgit2"] }
libc = { workspace = true }
modified crates/radicle/src/git/canonical.rs
@@ -1,3 +1,6 @@
+
pub mod rules;
+
pub use rules::{MatchedRule, RawRule, Rules, ValidRule};
+

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

added crates/radicle/src/git/canonical/rules.rs
@@ -0,0 +1,1206 @@
+
//! Implementation of RIP-0004 Canonical References
+
//!
+
//! [`RawRules`] is intended to be deserialized and then validated into a set of
+
//! [`Rules`]. These can then be used to see if a [`Qualified`] reference
+
//! matches any of the rules, using [`Rules::matches`]. Using [`Canonical`] with
+
//! the first matched rule, and this can be used to calculate the
+
//! [`Canonical::quorum`].
+

+
use core::fmt;
+
use std::cmp::Ordering;
+
use std::collections::BTreeMap;
+
use std::sync::LazyLock;
+

+
use nonempty::NonEmpty;
+
use serde::{Deserialize, Serialize};
+
use serde_json as json;
+
use thiserror::Error;
+

+
use crate::git;
+
use crate::git::canonical::Canonical;
+
use crate::git::fmt::{refname, RefString};
+
use crate::git::refspec::QualifiedPattern;
+
use crate::git::Qualified;
+
use crate::identity::{doc, Did};
+
use crate::storage::git::Repository;
+

+
const ASTERISK: char = '*';
+

+
static REFS_RAD: LazyLock<RefString> = LazyLock::new(|| refname!("refs/rad"));
+

+
/// Private trait to ensure that not any `Rule` can be deserialized.
+
/// Implementations are provided for `Allowed` and `usize` so that `RawRule`s
+
/// can be deserialized, while `ValidRule`s cannot – preventing deserialization
+
/// bugs for that type.
+
trait Sealed {}
+
impl Sealed for Allowed {}
+
impl Sealed for usize {}
+

+
/// A `Pattern` is a `QualifiedPattern` reference, however, it disallows any
+
/// references under the `refs/rad` hierarchy.
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(into = "QualifiedPattern", try_from = "QualifiedPattern")]
+
pub struct Pattern(QualifiedPattern<'static>);
+

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

+
impl From<Pattern> for QualifiedPattern<'static> {
+
    fn from(Pattern(pattern): Pattern) -> Self {
+
        pattern
+
    }
+
}
+

+
impl<'a> TryFrom<QualifiedPattern<'a>> for Pattern {
+
    type Error = PatternError;
+

+
    fn try_from(pattern: QualifiedPattern<'a>) -> Result<Self, Self::Error> {
+
        if pattern.starts_with(REFS_RAD.as_str()) {
+
            Err(PatternError::ProtectedRef {
+
                prefix: (*REFS_RAD).clone(),
+
                pattern: pattern.to_owned(),
+
            })
+
        } else {
+
            Ok(Self(pattern.to_owned()))
+
        }
+
    }
+
}
+

+
impl<'a> TryFrom<Qualified<'a>> for Pattern {
+
    type Error = PatternError;
+

+
    fn try_from(name: Qualified<'a>) -> Result<Self, Self::Error> {
+
        Self::try_from(QualifiedPattern::from(name))
+
    }
+
}
+

+
impl Pattern {
+
    /// Check if the `refname` matches the rule's `refspec`.
+
    pub fn matches(&self, refname: &Qualified) -> bool {
+
        // N.b. Git's refspecs do not quite match with glob-star semantics. A
+
        // single `*` in a refspec is expected to match all references under
+
        // that namespace, even if they are further down the hierarchy.
+
        // Thus, the following rules are applied:
+
        //
+
        //   - a trailing `*` changes to `**/*`
+
        //   - a `*` in between path components changes to `**`
+
        let spec = match self.0.as_str().split_once(ASTERISK) {
+
            None => self.0.to_string(),
+
            // Expand `refs/tags/*` to `refs/tags/**/*`
+
            Some((prefix, "")) => {
+
                let mut spec = prefix.to_string();
+
                spec.push_str("**/*");
+
                spec
+
            }
+
            // Expand `refs/tags/*/v1.0` to `refs/tags/**/v1.0`
+
            Some((prefix, suffix)) => {
+
                let mut spec = prefix.to_string();
+
                spec.push_str("**");
+
                spec.push_str(suffix);
+
                spec
+
            }
+
        };
+
        fast_glob::glob_match(&spec, refname.as_str())
+
    }
+
}
+

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

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

+
/// Patterns are ordered by their specificity.
+
///
+
/// This is heavily influenced by the evaluation priority of Rules. For a
+
/// candidate reference name, we want the rule associated with the most specific
+
/// pattern to apply, i.e. to take priority over all other rules with less
+
/// specific patterns.
+
///
+
/// For two patterns `φ` and `ψ`, we say that "`φ` is more specific than `ψ`", denoted
+
/// `φ < ψ` if:
+
///
+
///  1. The number of components in `φ` is larger than the number of components
+
///     in `ψ`. (Note that the number of components is equal to the number of
+
///     occurrences of the symbol '/' in the pattern, plus 1).
+
///     The justification is, that refnames might be interpreted as a hierarchy
+
///     where a match on more components would mean a match at a lower level in
+
///     the hierarchy, thus being more specific.
+
///     Imagine a refname hierarchy that maps to a corporate hierarchy.
+
///     The pattern "department-1" matches all refnames that are administered
+
///     by a particular department, and thus is not very specific.
+
///     To contrast, the pattern "department-1/team-a/project-i/nice-feature"
+
///     is very specific as it matches all refnames that relate to the
+
///     development of a particular feature for a particular project by a
+
///     particular team.
+
///     Note that this would also apply when the connection between the `φ` and `ψ`
+
///     is not as obvious, e.g. also `a/b/c/d/* < */x`.
+
///
+
/// (Note that for the following items, one may assume that `φ` and `ψ` have the
+
/// same number of components.)
+
///
+
///  2. If path component i of `φ`, denoted `φ[i]`, is more specific than path
+
///     component i of `ψ`, denoted `ψ[i]`. This is the case if:
+
///      a. `φ[i]` does not contain an asterisk and `ψ[i]` contains an asterisk,
+
///         i.e. the symbol `*`, e.g. `a < * and abc < a*`.
+
///         Note that this is important to capture specificity accross
+
///         components, i.e. to conclude that `a/b/* < a/*/c`.
+
///      b. Both `φ[i]` and `ψ[i]` contain an asterisk.
+
///          A. The asterisk in `φ[i]` is further right than the asterisk in `φ[i]`,
+
///             e.g. `aa* < a*`.
+
///          B. The asterisk in `φ[i]` and `ψ[i]` is equally far to the right,
+
///             and `φ[i]` is longer than `ψ[i]`, e.g. `a*b < a*`.
+
///
+
///  3. Otherwise, fall back to a lexicographic ordering.
+
///
+
/// Some examples (justification in parentheses):
+
///
+
/// ```text, no_run
+
/// refs/tags/release/candidates/* <(1.)   refs/tags/release/* <(1.) refs/tags/*
+
/// refs/tags/v1.0                 <(2.a.) refs/tags/*
+
/// refs/heads/*                   <(3.)   refs/tags/*
+
/// refs/heads/main                <(3.)   refs/tags/v1.0
+
/// ```
+
impl Ord for Pattern {
+
    fn cmp(&self, other: &Self) -> Ordering {
+
        #[derive(Debug, Clone, Copy)]
+
        #[repr(i8)]
+
        enum ComponentOrdering {
+
            MatchLength(Ordering),
+
            Lexicographic(Ordering),
+
        }
+

+
        impl ComponentOrdering {
+
            fn merge(&mut self, other: Self) {
+
                *self = match (*self, other) {
+
                    (Self::Lexicographic(Ordering::Equal), Self::Lexicographic(other)) => {
+
                        Self::Lexicographic(other)
+
                    }
+
                    (Self::Lexicographic(_), Self::MatchLength(other)) => Self::MatchLength(other),
+
                    (Self::MatchLength(Ordering::Equal), Self::MatchLength(other)) => {
+
                        Self::MatchLength(other)
+
                    }
+
                    (clone, _) => clone,
+
                }
+
            }
+
        }
+

+
        impl From<ComponentOrdering> for Ordering {
+
            fn from(value: ComponentOrdering) -> Self {
+
                match value {
+
                    ComponentOrdering::MatchLength(ordering) => ordering,
+
                    ComponentOrdering::Lexicographic(ordering) => ordering,
+
                }
+
            }
+
        }
+

+
        impl Default for ComponentOrdering {
+
            /// The weakest value of Self, which will be absorbed by any
+
            /// other in [`ComponentOrdering::merge`].
+
            fn default() -> Self {
+
                Self::Lexicographic(Ordering::Equal)
+
            }
+
        }
+

+
        use git::refspec::Component;
+

+
        fn cmp_component(lhs: Component<'_>, rhs: Component<'_>) -> ComponentOrdering {
+
            let (l, r) = (lhs.as_str(), rhs.as_str());
+
            match (l.find(ASTERISK), r.find(ASTERISK)) {
+
                // (2.a.)
+
                (Some(_), None) => ComponentOrdering::MatchLength(Ordering::Greater),
+
                // (2.a.)
+
                (None, Some(_)) => ComponentOrdering::MatchLength(Ordering::Less),
+
                (Some(li), Some(ri)) => {
+
                    if li != ri {
+
                        // (2.b.A)
+
                        ComponentOrdering::MatchLength(li.cmp(&ri).reverse())
+
                    } else if l.len() != r.len() {
+
                        // (2.b.B)
+
                        ComponentOrdering::MatchLength(l.len().cmp(&r.len()).reverse())
+
                    } else {
+
                        // (3.)
+
                        ComponentOrdering::Lexicographic(l.cmp(r))
+
                    }
+
                }
+
                // (3.)
+
                (None, None) => ComponentOrdering::Lexicographic(l.cmp(r)),
+
            }
+
        }
+

+
        let mut result = ComponentOrdering::default();
+
        let mut lhs = self.0.components();
+
        let mut rhs = other.0.components();
+
        loop {
+
            match (lhs.next(), rhs.next()) {
+
                (None, Some(_)) => return Ordering::Greater, // (1.)
+
                (Some(_), None) => return Ordering::Less,    // (1.)
+
                (Some(lhs), Some(rhs)) => {
+
                    result.merge(cmp_component(lhs, rhs));
+
                }
+
                (None, None) => return result.into(),
+
            }
+
        }
+
    }
+
}
+

+
/// A [`Rule`] that can be serialized and deserialized safely.
+
///
+
/// Should be converted to a [`ValidRule`] via [`Rule::validate`].
+
pub type RawRule = Rule<Allowed, usize>;
+

+
impl RawRule {
+
    /// Validate the `Rule` into a form that can be used for calculating
+
    /// canonical references.
+
    ///
+
    /// The `resolve` callback is to allow the caller to specify the DIDs of the
+
    /// identity document, in the case that the allowed value is
+
    /// [`Allowed::Delegates`].
+
    pub fn validate<R>(self, resolve: &mut R) -> Result<ValidRule, ValidationError>
+
    where
+
        R: Fn() -> doc::Delegates,
+
    {
+
        let Self {
+
            allow: delegates,
+
            threshold,
+
            ..
+
        } = self;
+
        let allow = match &delegates {
+
            Allowed::Delegates => ResolvedDelegates::Delegates(resolve()),
+
            Allowed::Set(delegates) => {
+
                let valid =
+
                    doc::Delegates::new(delegates.clone()).map_err(ValidationError::from)?;
+
                ResolvedDelegates::Set(valid)
+
            }
+
        };
+
        let threshold = doc::Threshold::new(threshold, &allow)?;
+
        Ok(Rule {
+
            allow,
+
            threshold,
+
            extensions: self.extensions,
+
        })
+
    }
+
}
+

+
/// A set of `RawRule`s that can be serialized and deserialized.
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct RawRules {
+
    /// The reference pattern that this rule applies to.
+
    ///
+
    /// Note that this can be a fully-qualified pattern, e.g. `refs/heads/qa`,
+
    /// as well as a wild-card pattern, e.g. `refs/tags/*`.
+
    #[serde(flatten)]
+
    pub rules: BTreeMap<Pattern, RawRule>,
+
}
+

+
impl RawRules {
+
    /// Returns an iterator over the [`Pattern`] and [`RawRule`] in the set of
+
    /// rules.
+
    pub fn iter(&self) -> impl Iterator<Item = (&Pattern, &RawRule)> {
+
        self.rules.iter()
+
    }
+

+
    /// Add a new [`RawRule`] to the set of rules.
+
    ///
+
    /// Returns the replaced rule, if it existed.
+
    pub fn insert(&mut self, pattern: Pattern, rule: RawRule) -> Option<RawRule> {
+
        self.rules.insert(pattern, rule)
+
    }
+

+
    /// Remove the rule that matches the `pattern` parameter.
+
    ///
+
    /// Returns the rule if it existed.
+
    pub fn remove(&mut self, pattern: &Pattern) -> Option<RawRule> {
+
        self.rules.remove(pattern)
+
    }
+

+
    /// Check to see if there is an exact match for `refname` in the rules.
+
    pub fn exact_match(&self, refname: &Qualified) -> bool {
+
        let refname = refname.as_str();
+
        self.rules
+
            .iter()
+
            .any(|(pattern, _)| pattern.0.as_str() == refname)
+
    }
+

+
    /// Check if the `refname` matches any existing rules, including glob
+
    /// matches.
+
    pub fn matches<'a, 'b>(
+
        &self,
+
        refname: &Qualified<'b>,
+
    ) -> impl Iterator<Item = (&Pattern, &RawRule)> + use<'a, '_, 'b> {
+
        let refname = refname.clone();
+
        self.rules
+
            .iter()
+
            .filter(move |(pattern, _)| pattern.matches(&refname))
+
    }
+
}
+

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

+
impl From<BTreeMap<Pattern, RawRule>> for RawRules {
+
    fn from(rules: BTreeMap<Pattern, RawRule>) -> Self {
+
        RawRules { rules }
+
    }
+
}
+

+
impl FromIterator<(Pattern, RawRule)> for RawRules {
+
    fn from_iter<T: IntoIterator<Item = (Pattern, RawRule)>>(iter: T) -> Self {
+
        iter.into_iter().collect::<BTreeMap<_, _>>().into()
+
    }
+
}
+

+
impl IntoIterator for RawRules {
+
    type Item = (Pattern, RawRule);
+
    type IntoIter = std::collections::btree_map::IntoIter<Pattern, RawRule>;
+

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

+
/// A [`Rule`] that has been validated. See [`Rules`] and [`Rules::matches`] for
+
/// its main usage.
+
///
+
/// N.b. a `ValidRule` can be serialized, however, it cannot be deserialized.
+
/// This is due to the fact that the `allow` field may have a value of
+
/// `delegates`. In those cases the value needs to be looked up via the identity
+
/// document and validated.
+
pub type ValidRule = Rule<ResolvedDelegates, doc::Threshold>;
+

+
impl ValidRule {
+
    /// Initialize a `ValidRule` for the default branch, given by `name`. The
+
    /// rule will contain the single `did` as the allowed DID, and use a
+
    /// threshold of `1`.
+
    ///
+
    /// Note that the serialization of the rule will use the `delegates` token
+
    /// for the rule. E.g.
+
    /// ```json
+
    /// {
+
    ///   "pattern": "refs/heads/main",
+
    ///   "allow": ["did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi"],
+
    ///   "threshold": 1
+
    /// }
+
    /// ```
+
    ///
+
    /// # Errors
+
    ///
+
    /// If the `name` reference begins with `refs/rad`.
+
    pub fn default_branch(did: Did, name: &git::RefStr) -> Result<(Pattern, Self), PatternError> {
+
        let pattern = Pattern::try_from(git::refs::branch(name).to_owned())?;
+
        let rule = Self {
+
            allow: ResolvedDelegates::Delegates(doc::Delegates::from(did)),
+
            // N.B. this needs to be the minimum since we only have one
+
            // delegate.
+
            threshold: doc::Threshold::MIN,
+
            extensions: json::Map::new(),
+
        };
+
        Ok((pattern, rule))
+
    }
+
}
+

+
impl From<ValidRule> for RawRule {
+
    fn from(rule: ValidRule) -> Self {
+
        let Rule {
+
            allow,
+
            threshold,
+
            extensions,
+
        } = rule;
+
        Self {
+
            allow: allow.into(),
+
            threshold: threshold.into(),
+
            extensions,
+
        }
+
    }
+
}
+

+
/// A representation of a set of allowed DIDs.
+
///
+
/// `Allowed` is used in a `RawRule`.
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
pub enum Allowed {
+
    /// Pointer to the identity document's set of delegates.
+
    #[serde(rename = "delegates")]
+
    #[default]
+
    Delegates,
+
    /// Explicit list of allowed DIDs.
+
    ///
+
    /// The elements of the list of allowed DIDs will be made unique, i.e.
+
    /// duplicate DIDs will be discarded.
+
    ///
+
    /// # Validation
+
    ///
+
    /// The list of allowed DIDs, `allowed`, must satisfy:
+
    /// ```text
+
    /// 1 <= allowed.len() <= 255
+
    /// ```
+
    #[serde(untagged)]
+
    Set(NonEmpty<Did>),
+
}
+

+
impl From<NonEmpty<Did>> for Allowed {
+
    fn from(dids: NonEmpty<Did>) -> Self {
+
        Self::Set(dids)
+
    }
+
}
+

+
impl From<Did> for Allowed {
+
    fn from(did: Did) -> Self {
+
        Self::Set(NonEmpty::new(did))
+
    }
+
}
+

+
/// A marker `enum` that is used in a [`ValidRule`].
+
///
+
/// It ensures that a rule that has been deserialized, resolving the `delegates`
+
/// token to a set of DIDs, is still serialized back to the `delegates` token –
+
/// as opposed to serializing it to the set of DIDs.
+
///
+
/// The variants mirror the [`Allowed::Delegates`] and [`Allowed::Set`]
+
/// variants.
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+
#[serde(into = "Allowed")]
+
pub enum ResolvedDelegates {
+
    Delegates(doc::Delegates),
+
    Set(doc::Delegates),
+
}
+

+
impl From<ResolvedDelegates> for Allowed {
+
    fn from(ds: ResolvedDelegates) -> Self {
+
        match ds {
+
            ResolvedDelegates::Delegates(_) => Self::Delegates,
+
            ResolvedDelegates::Set(ds) => Self::Set(ds.into()),
+
        }
+
    }
+
}
+

+
impl std::ops::Deref for ResolvedDelegates {
+
    type Target = doc::Delegates;
+

+
    fn deref(&self) -> &Self::Target {
+
        match self {
+
            ResolvedDelegates::Delegates(ds) => ds,
+
            ResolvedDelegates::Set(ds) => ds,
+
        }
+
    }
+
}
+

+
/// A reference that has been matched against a [`ValidRule`].
+
///
+
/// Can be constructed by using [`Rules::matches`].
+
#[derive(Debug)]
+
pub struct MatchedRule<'a> {
+
    refname: Qualified<'a>,
+
    rule: ValidRule,
+
}
+

+
impl MatchedRule<'_> {
+
    /// Return the reference name that was used for checking if it was a match.
+
    pub fn refname(&self) -> &Qualified {
+
        &self.refname
+
    }
+

+
    /// Return the rule that was matched.
+
    pub fn rule(&self) -> &ValidRule {
+
        &self.rule
+
    }
+

+
    /// Return the allowed DIDs for the matched rule.
+
    pub fn allowed(&self) -> &doc::Delegates {
+
        self.rule().allowed()
+
    }
+

+
    /// Return the [`doc::Threshold`] for the matched rule.
+
    pub fn threshold(&self) -> &doc::Threshold {
+
        self.rule().threshold()
+
    }
+

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

+
/// A set of valid [`Rule`]s, where the set of DIDs and threshold are fully
+
/// resolved and valid. Since the rules are constructed via a `BTreeMap`, they
+
/// cannot be duplicated.
+
///
+
/// To construct the set of rules, use [`Rules::from_raw`], which validates a
+
/// set of [`RawRule`]s, and their [`Pattern`] references, into a set of
+
/// [`ValidRule`]s.
+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
+
pub struct Rules {
+
    #[serde(flatten)]
+
    rules: BTreeMap<Pattern, ValidRule>,
+
}
+

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

+
impl<'a> IntoIterator for &'a Rules {
+
    type Item = (&'a Pattern, &'a ValidRule);
+
    type IntoIter = std::collections::btree_map::Iter<'a, Pattern, ValidRule>;
+

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

+
impl IntoIterator for Rules {
+
    type Item = (Pattern, ValidRule);
+
    type IntoIter = std::collections::btree_map::IntoIter<Pattern, ValidRule>;
+

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

+
impl From<Rules> for RawRules {
+
    fn from(Rules { rules }: Rules) -> Self {
+
        Self {
+
            rules: rules
+
                .into_iter()
+
                .map(|(pattern, rule)| (pattern, rule.into()))
+
                .collect(),
+
        }
+
    }
+
}
+

+
impl Rules {
+
    /// Returns an iterator over the [`Pattern`] and [`ValidRule`] in the set of
+
    /// rules.
+
    pub fn iter(&self) -> impl Iterator<Item = (&Pattern, &ValidRule)> {
+
        self.rules.iter()
+
    }
+

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

+
    /// Construct a set of `Rules` given a set of `RawRule`s.
+
    pub fn from_raw<R>(
+
        rules: impl IntoIterator<Item = (Pattern, RawRule)>,
+
        resolve: &mut R,
+
    ) -> Result<Self, ValidationError>
+
    where
+
        R: Fn() -> doc::Delegates,
+
    {
+
        let valid = rules
+
            .into_iter()
+
            .map(|(pattern, rule)| rule.validate(resolve).map(|rule| (pattern, rule)))
+
            .collect::<Result<_, _>>()?;
+
        Ok(Self { rules: valid })
+
    }
+

+
    /// Return the first matching rule for the given `refname`, if there is any.
+
    ///
+
    /// N.b. it will find the first rule that is most specific for the given
+
    /// `refname`.
+
    pub fn matches<'a>(&self, refname: Qualified<'a>) -> Option<MatchedRule<'a>> {
+
        self.rules
+
            .iter()
+
            .find(|(pattern, _)| pattern.matches(&refname))
+
            .map(|(_, rule)| MatchedRule {
+
                refname,
+
                rule: rule.clone(),
+
            })
+
    }
+
}
+

+
/// A `Rule` defines how a reference or set of references can be made canonical,
+
/// i.e. have a top-level `refs/*` entry – see [`Pattern`].
+
///
+
/// The [`Rule::allowed`] type is generic to allow for [`Allowed`] to be used
+
/// for serialization and deserialization, however, the use of
+
/// [`Rule::validate`] should be used to get a valid rule.
+
///
+
/// The [`Rule::threshold`], similarly, allows for [`doc::Threshold`] to be used, and
+
/// [`Rule::validate`] should be used to get a valid rule.
+
// N.b. it's safe to derive `Serialize` since we only allow constructing a
+
// `Rule` via `Rule::validate`, and we seal `Deserialize` by ensuring that only
+
// `RawRule` can be deserialized.
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
#[serde(bound(deserialize = "D: Sealed + Deserialize<'de>, T: Sealed + Deserialize<'de>"))]
+
pub struct Rule<D, T> {
+
    /// The set of allowed DIDs that are considered for voting for this rule.
+
    allow: D,
+
    /// The threshold the votes must pass for the reference(s) to be considered
+
    /// canonical.
+
    threshold: T,
+

+
    /// Optional extensions in rules. This is intended to preserve backwards and
+
    /// forward-compatibility
+
    #[serde(skip_serializing_if = "json::Map::is_empty")]
+
    #[serde(flatten)]
+
    extensions: json::Map<String, json::Value>,
+
}
+

+
impl<D, T> Rule<D, T> {
+
    /// Construct a new `Rule` with the given `allow` and `threshold`.
+
    pub fn new(allow: D, threshold: T) -> Self {
+
        Self {
+
            allow,
+
            threshold,
+
            extensions: json::Map::new(),
+
        }
+
    }
+

+
    /// Get the set of DIDs this `Rule` was created with.
+
    pub fn allowed(&self) -> &D {
+
        &self.allow
+
    }
+

+
    /// Get the set of threshold this `Rule` was created with.
+
    pub fn threshold(&self) -> &T {
+
        &self.threshold
+
    }
+

+
    /// Get the extensions that may have been added to this `Rule`.
+
    pub fn extensions(&self) -> &json::Map<String, json::Value> {
+
        &self.extensions
+
    }
+

+
    /// If the [`Rule::extensions`] is not set, the provided `extensions` will
+
    /// be used.
+
    ///
+
    /// Otherwise, it expects that the JSON value is a `Map` and the
+
    /// `extensions` are merged. If the existing value is any other kind of JSON
+
    /// value, this is a no-op.
+
    pub fn add_extensions(&mut self, extensions: impl Into<json::Map<String, json::Value>>) {
+
        self.extensions.extend(extensions.into());
+
    }
+
}
+

+
#[derive(Debug, Error)]
+
pub enum PatternError {
+
    #[error("cannot create rule for '{pattern}' since references under '{prefix}' are protected")]
+
    ProtectedRef {
+
        prefix: RefString,
+
        pattern: QualifiedPattern<'static>,
+
    },
+
}
+

+
#[derive(Debug, Error)]
+
pub enum ValidationError {
+
    #[error(transparent)]
+
    Threshold(#[from] doc::ThresholdError),
+
    #[error(transparent)]
+
    Delegates(#[from] doc::DelegatesError),
+
    #[error("cannot create rule for reserved `rad` references '{pattern}'")]
+
    RadRef { pattern: QualifiedPattern<'static> },
+
}
+

+
#[derive(Debug, Error)]
+
pub enum CanonicalError {
+
    #[error(transparent)]
+
    Git(#[from] git::raw::Error),
+
    #[error(transparent)]
+
    References(#[from] git::ext::Error),
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod tests {
+
    use std::collections::BTreeMap;
+

+
    use nonempty::nonempty;
+

+
    use crate::crypto::{test::signer::MockSigner, Signer};
+
    use crate::git;
+
    use crate::git::refspec::qualified_pattern;
+
    use crate::git::RefString;
+
    use crate::identity::doc::Doc;
+
    use crate::identity::Visibility;
+
    use crate::node::device::Device;
+
    use crate::rad;
+
    use crate::storage::refs::{IDENTITY_BRANCH, IDENTITY_ROOT, SIGREFS_BRANCH};
+
    use crate::storage::{git::transport, ReadStorage};
+
    use crate::test::{arbitrary, fixtures};
+
    use crate::Storage;
+

+
    use super::*;
+

+
    fn roundtrip(rule: &Rule<Allowed, usize>) {
+
        let json = serde_json::to_string(rule).unwrap();
+
        assert_eq!(
+
            *rule,
+
            serde_json::from_str(&json).unwrap(),
+
            "failed to roundtrip: {json}"
+
        )
+
    }
+

+
    fn did(s: &str) -> Did {
+
        s.parse().unwrap()
+
    }
+

+
    fn pattern(qp: QualifiedPattern<'static>) -> Pattern {
+
        Pattern::try_from(qp).unwrap()
+
    }
+

+
    fn resolve_from_doc(doc: &Doc) -> doc::Delegates {
+
        doc.delegates().clone()
+
    }
+

+
    fn tag(name: RefString, head: git2::Oid, repo: &git2::Repository) -> git::Oid {
+
        let commit = fixtures::commit(name.as_str(), &[head], repo);
+
        let target = repo.find_object(*commit, None).unwrap();
+
        let tagger = repo.signature().unwrap();
+
        repo.tag(name.as_str(), &target, &tagger, name.as_str(), false)
+
            .unwrap()
+
            .into()
+
    }
+

+
    #[test]
+
    fn test_roundtrip() {
+
        let rule1 = Rule::new(Allowed::Delegates, 1);
+
        let rule2 = Rule::new(Allowed::Delegates, 1);
+
        let rule3 = Rule::new(Allowed::Delegates, 1);
+
        let mut rule4 = Rule::new(
+
            Allowed::Set(nonempty![
+
                did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"),
+
                did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"),
+
            ]),
+
            2,
+
        );
+
        rule4.add_extensions(
+
            serde_json::json!({
+
                "foo": "bar",
+
                "quux": 5,
+
            })
+
            .as_object()
+
            .cloned()
+
            .unwrap(),
+
        );
+
        roundtrip(&rule1);
+
        roundtrip(&rule2);
+
        roundtrip(&rule3);
+
        roundtrip(&rule4);
+
    }
+

+
    #[test]
+
    fn test_deserialization() {
+
        let examples = r#"
+
{
+
  "refs/heads/main": {
+
    "threshold": 2,
+
    "allow": [
+
      "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K",
+
      "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"
+
    ]
+
  },
+
  "refs/tags/releases/*": {
+
    "threshold": 2,
+
    "allow": [
+
      "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56",
+
      "did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP",
+
      "did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax"
+
    ]
+
  },
+
  "refs/heads/development": {
+
    "threshold": 1,
+
    "allow": [
+
      "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG"
+
    ]
+
  },
+
  "refs/heads/release/*": {
+
    "threshold": 1,
+
    "allow": "delegates"
+
  }
+
}
+
 "#;
+
        let expected = [
+
            (
+
                pattern(qualified_pattern!("refs/heads/main")),
+
                Rule::new(
+
                    Allowed::Set(nonempty![
+
                        did("did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K"),
+
                        did("did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"),
+
                    ]),
+
                    2,
+
                ),
+
            ),
+
            (
+
                pattern(qualified_pattern!("refs/tags/releases/*")),
+
                Rule::new(
+
                    Allowed::Set(nonempty![
+
                        did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"),
+
                        did("did:key:z6Mkq2E5Se5H9gk1DsL1EMwR2t4CqSg3GFkNN2UeG4FNqXoP"),
+
                        did("did:key:z6MkqRmXW5fbP9hJ1Y8j2N4CgVdJ2XJ6TsyXYf3FQ2NJgXax")
+
                    ]),
+
                    2,
+
                ),
+
            ),
+
            (
+
                pattern(qualified_pattern!("refs/heads/development")),
+
                Rule::new(
+
                    Allowed::Set(nonempty![did(
+
                        "did:key:z6MkhH7ENYE62JAjTiRZPU71MGZ6xCwnbyHHWfrBu3fr6PVG"
+
                    )]),
+
                    1,
+
                ),
+
            ),
+
            (
+
                pattern(qualified_pattern!("refs/heads/release/*")),
+
                Rule::new(Allowed::Delegates, 1),
+
            ),
+
        ]
+
        .into_iter()
+
        .collect::<RawRules>();
+
        let rules = serde_json::from_str::<BTreeMap<Pattern, RawRule>>(examples)
+
            .unwrap()
+
            .into();
+
        assert_eq!(expected, rules)
+
    }
+

+
    #[test]
+
    fn test_order() {
+
        assert!(
+
            pattern(qualified_pattern!("a/b/c/d/*")) < pattern(qualified_pattern!("*/x")),
+
            "example 1"
+
        );
+
        assert!(
+
            pattern(qualified_pattern!("a")) < pattern(qualified_pattern!("*")),
+
            "example 2.a"
+
        );
+
        assert!(
+
            pattern(qualified_pattern!("abc")) < pattern(qualified_pattern!("a*")),
+
            "example 2.a"
+
        );
+
        assert!(
+
            pattern(qualified_pattern!("a/b/*")) < pattern(qualified_pattern!("a/*/c")),
+
            "example 2.a"
+
        );
+
        assert!(
+
            pattern(qualified_pattern!("aa*")) < pattern(qualified_pattern!("a*")),
+
            "example 2.b.A"
+
        );
+
        assert!(
+
            pattern(qualified_pattern!("a*b")) < pattern(qualified_pattern!("a*")),
+
            "example 2.b.B"
+
        );
+

+
        let pattern01 = pattern(qualified_pattern!("refs/tags/*"));
+
        let pattern02 = pattern(qualified_pattern!("refs/tags/v1"));
+
        let pattern04 = pattern(qualified_pattern!("refs/tags/v1.0.0"));
+
        let pattern05 = pattern(qualified_pattern!("refs/tags/release/v1.0.0"));
+
        let pattern03 = pattern(qualified_pattern!("refs/heads/main"));
+
        let pattern06 = pattern(qualified_pattern!("refs/tags/*/v1.0.0"));
+

+
        let pattern07 = pattern(qualified_pattern!("refs/tags/x*"));
+
        let pattern08 = pattern(qualified_pattern!("refs/tags/xx*"));
+

+
        let pattern09 = pattern(qualified_pattern!("refs/foos/*"));
+

+
        let pattern10 = pattern(qualified_pattern!("a"));
+
        let pattern11 = pattern(qualified_pattern!("b"));
+

+
        let pattern12 = pattern(qualified_pattern!("a/*"));
+
        let pattern13 = pattern(qualified_pattern!("b/*"));
+

+
        let pattern14 = pattern(qualified_pattern!("a/*/ab"));
+
        let pattern15 = pattern(qualified_pattern!("a/*/a"));
+

+
        let pattern16 = pattern(qualified_pattern!("a/*/b"));
+
        let pattern17 = pattern(qualified_pattern!("a/*/a"));
+

+
        // Test priority for path specificity
+
        assert!(
+
            pattern06 < pattern02,
+
            "match for 06 is always more specific since it has more components"
+
        );
+
        assert!(pattern02 < pattern01, "match for 02 is also match for 01");
+
        assert!(pattern08 < pattern07, "match for 08 is also match for 07");
+
        // Test equality
+
        assert!(pattern02 == pattern02);
+
        // Test lexicographical fallback when paths are equally specific
+
        assert!(pattern02 < pattern04);
+
        assert!(pattern03 < pattern01);
+
        assert!(pattern09 < pattern01);
+
        assert!(pattern10 < pattern11);
+
        assert!(pattern12 < pattern13);
+
        assert!(pattern15 < pattern14);
+
        assert!(
+
            pattern17 < pattern16,
+
            "matches have same length, but lexicographically, 'a' < 'b'"
+
        );
+

+
        // Test example from docs
+
        let pattern18 = pattern(qualified_pattern!("refs/tags/release/candidates/*"));
+
        let pattern19 = pattern(qualified_pattern!("refs/tags/release/*"));
+
        let pattern20 = pattern(qualified_pattern!("refs/tags/*"));
+

+
        assert!(pattern18 < pattern19);
+
        assert!(pattern19 < pattern20);
+

+
        let pattern21 = pattern(qualified_pattern!("refs/heads/dev"));
+

+
        assert!(pattern21 < pattern03);
+

+
        let mut patterns = [
+
            pattern01.clone(),
+
            pattern02.clone(),
+
            pattern03.clone(),
+
            pattern04.clone(),
+
            pattern05.clone(),
+
            pattern06.clone(),
+
        ];
+
        patterns.sort();
+

+
        assert_eq!(
+
            patterns,
+
            [pattern05, pattern06, pattern03, pattern02, pattern04, pattern01]
+
        );
+
    }
+

+
    #[test]
+
    fn test_deserialize_extensions() {
+
        let example = r#"
+
{
+
  "threshold": 2,
+
  "allow": [
+
    "did:key:z6MkpQTLwr8QyADGmBGAMsGttvWzP4PojUMs4hREZW5T5E3K",
+
    "did:key:z6MknG1nYDftMYUQ7eTBSGgqB2PL1xK5Pif33J3sRym3e8ye"
+
  ],
+
  "foo": "bar",
+
  "quux": 5
+
}
+
"#;
+
        let rule = serde_json::from_str::<Rule<Allowed, usize>>(example).unwrap();
+
        assert!(!rule.extensions().is_empty());
+
        let extensions = rule.extensions();
+
        assert_eq!(
+
            extensions.get("foo"),
+
            Some(serde_json::Value::String("bar".to_string())).as_ref()
+
        );
+
        assert_eq!(
+
            extensions.get("quux"),
+
            Some(serde_json::Value::Number(5.into())).as_ref()
+
        );
+
    }
+

+
    #[test]
+
    fn test_rule_validate_success() {
+
        let doc = arbitrary::gen::<Doc>(1);
+
        let delegates = Allowed::Set(doc.delegates().as_ref().clone());
+
        let threshold = doc.majority();
+

+
        let rule = Rule::new(delegates, threshold);
+
        let result = rule.validate(&mut || resolve_from_doc(&doc));
+
        assert!(result.is_ok(), "failed to validate doc: {result:?}");
+

+
        let rule = Rule::new(Allowed::Delegates, 1);
+
        let result = rule.validate(&mut || resolve_from_doc(&doc));
+
        assert!(result.is_ok(), "failed to validate doc: {result:?}");
+
    }
+

+
    #[test]
+
    fn test_rule_validate_failures() {
+
        let doc = arbitrary::gen::<Doc>(1);
+
        let pattern = pattern(qualified_pattern!("refs/heads/main"));
+

+
        assert!(matches!(
+
            Rule::new(Allowed::Delegates, 256).validate(&mut || resolve_from_doc(&doc)),
+
            Err(ValidationError::Threshold(_))
+
        ));
+

+
        let threshold = doc.delegates().len().saturating_add(1);
+
        assert!(matches!(
+
            Rule::new(Allowed::Delegates, threshold).validate(&mut || resolve_from_doc(&doc)),
+
            Err(ValidationError::Threshold(_))
+
        ));
+

+
        let delegates = NonEmpty::from_vec(arbitrary::vec::<Did>(256)).unwrap();
+
        assert!(matches!(
+
            Rule::new(delegates.into(), 1).validate(&mut || resolve_from_doc(&doc)),
+
            Err(ValidationError::Delegates(_))
+
        ));
+

+
        let delegates = nonempty![
+
            did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"),
+
            did("did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56")
+
        ];
+
        let expected = Rule {
+
            allow: ResolvedDelegates::Set(
+
                doc::Delegates::new(nonempty![did(
+
                    "did:key:z6MknLWe8A7UJxvTfY36JcB8XrP1KTLb5HFTX38hEmdY3b56"
+
                )])
+
                .unwrap(),
+
            ),
+
            threshold: doc::Threshold::MIN,
+
            extensions: json::Map::new(),
+
        };
+
        assert_eq!(
+
            Rule::new(delegates.into(), 1)
+
                .validate(&mut || resolve_from_doc(&doc))
+
                .unwrap(),
+
            expected,
+
        );
+

+
        // Duplicate rules are overwritten
+
        let rules = vec![
+
            (pattern.clone(), Rule::new(Allowed::Delegates, 1)),
+
            (
+
                pattern.clone(),
+
                Rule::new(doc.delegates().as_ref().clone().into(), 1),
+
            ),
+
        ];
+
        let expected = [(
+
            pattern,
+
            Rule::new(
+
                ResolvedDelegates::Set(doc.delegates().clone()),
+
                doc::Threshold::MIN,
+
            ),
+
        )]
+
        .into_iter()
+
        .collect::<Rules>();
+
        assert_eq!(
+
            Rules::from_raw(rules, &mut || resolve_from_doc(&doc)).unwrap(),
+
            expected
+
        );
+
    }
+

+
    #[test]
+
    fn test_canonical() {
+
        let tempdir = tempfile::tempdir().unwrap();
+
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
+

+
        transport::local::register(storage.clone());
+

+
        let delegate = Device::mock_from_seed([0xff; 32]);
+
        let contributor = MockSigner::from_seed([0xfe; 32]);
+
        let (repo, head) = fixtures::repository(tempdir.path().join("working"));
+
        let (rid, doc, _) = rad::init(
+
            &repo,
+
            "heartwood".try_into().unwrap(),
+
            "Radicle Heartwood Protocol & Stack",
+
            git::refname!("master"),
+
            Visibility::default(),
+
            &delegate,
+
            &storage,
+
        )
+
        .unwrap();
+

+
        let mut doc = doc.edit();
+
        // Ensure there is a second delegate for testing overlapping rules
+
        doc.delegate(contributor.public_key().into());
+

+
        // Create tags and keep track of their OIDs
+
        //
+
        // follows the `refs/tags/release/candidates/*` rule
+
        let failing_tag = git::refname!("release/candidates/v1.0");
+
        let tags = [
+
            // follows the `refs/tags/*` rule
+
            git::refname!("v1.0"),
+
            // follows the `refs/tags/release/*` rule
+
            git::refname!("release/v1.0"),
+
            failing_tag.clone(),
+
            // follows the `refs/tags/*` rule
+
            git::refname!("qa/v1.0"),
+
        ]
+
        .into_iter()
+
        .map(|name| {
+
            (
+
                git::lit::refs_tags(name.clone()).into(),
+
                tag(name, head, &repo),
+
            )
+
        })
+
        .collect::<BTreeMap<Qualified, _>>();
+

+
        git::push(
+
            &repo,
+
            &rad::REMOTE_NAME,
+
            [
+
                (
+
                    &git::qualified!("refs/tags/v1.0"),
+
                    &git::qualified!("refs/tags/v1.0"),
+
                ),
+
                (
+
                    &git::qualified!("refs/tags/release/v1.0"),
+
                    &git::qualified!("refs/tags/release/v1.0"),
+
                ),
+
                (
+
                    &git::qualified!("refs/tags/release/candidates/v1.0"),
+
                    &git::qualified!("refs/tags/release/candidates/v1.0"),
+
                ),
+
                (
+
                    &git::qualified!("refs/tags/qa/v1.0"),
+
                    &git::qualified!("refs/tags/qa/v1.0"),
+
                ),
+
            ],
+
        )
+
        .unwrap();
+

+
        let rules = Rules::from_raw(
+
            [
+
                (
+
                    pattern(qualified_pattern!("refs/tags/*")),
+
                    Rule::new(Allowed::Delegates, 1),
+
                ),
+
                (
+
                    pattern(qualified_pattern!("refs/tags/release/*")),
+
                    Rule::new(Allowed::Delegates, 1),
+
                ),
+
                // Ensure that none of the other rules apply by ensuring we need
+
                // both delegates to get the quorum of the
+
                // `refs/tags/release/candidates/v1.0` reference
+
                (
+
                    pattern(qualified_pattern!("refs/tags/release/candidates/*")),
+
                    Rule::new(Allowed::Delegates, 2),
+
                ),
+
            ],
+
            &mut || resolve_from_doc(&doc.clone().verified().unwrap()),
+
        )
+
        .unwrap();
+

+
        // All tags should succeed at getting their canonical commit other than the
+
        // candidates tag.
+
        let stored = storage.repository(rid).unwrap();
+
        let failing = git::Qualified::from(git::lit::refs_tags(failing_tag));
+
        for (refname, oid) in tags.iter() {
+
            let matched = rules.matches(refname.clone()).unwrap_or_else(|| {
+
                panic!("there should be a matching rule for {refname}, rules: {rules:#?}")
+
            });
+
            let canonical = matched.canonical(&stored).unwrap();
+
            if *refname == failing {
+
                assert!(canonical.quorum(&repo).is_err());
+
            } else {
+
                assert_eq!(
+
                    canonical
+
                        .quorum(&repo)
+
                        .unwrap_or_else(|e| panic!("quorum error for {refname}: {e}")),
+
                    *oid,
+
                )
+
            }
+
        }
+
    }
+

+
    #[test]
+
    fn test_special_branches() {
+
        assert!(Pattern::try_from((*IDENTITY_BRANCH).clone()).is_err());
+
        assert!(Pattern::try_from((*SIGREFS_BRANCH).clone()).is_err());
+
        assert!(Pattern::try_from((*IDENTITY_ROOT).clone()).is_err());
+
    }
+
}
modified crates/radicle/src/identity/doc.rs
@@ -477,6 +477,12 @@ impl AsRef<NonEmpty<Did>> for Delegates {
    }
}

+
impl From<Did> for Delegates {
+
    fn from(did: Did) -> Self {
+
        Self(NonEmpty::new(did))
+
    }
+
}
+

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