Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
policy: change the default scope from 'all' to 'follow'
Merged ade opened 3 months ago

As per the Security > Issue with default value for Scope being All zulip conversation. Change the default scope for cloned, seeded and newly initialised repositories from ‘all’ to ‘follow’.

30 files changed +412 -50 84320919 c96aea06
modified crates/radicle-cli/examples/rad-block.md
@@ -38,7 +38,7 @@ $ rad seed
╭───────────────────────────────────────────────────────────╮
│ Repository                          Name   Policy   Scope │
├───────────────────────────────────────────────────────────┤
-
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji          block    all   │
+
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji          block          │
╰───────────────────────────────────────────────────────────╯
```

modified crates/radicle-cli/examples/rad-clone-connect.md
@@ -3,7 +3,7 @@ automatically connect to the necessary seeds.

```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-clone-partial-fail.md
@@ -10,12 +10,13 @@ $ rad node routing
│ rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji   z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk │
╰──────────────────────────────────────────────────────────────────────────────────────╯
```
+

When she tries to clone, one of those will fail to fetch. But the clone command
still returns successfully.

```
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --timeout 3
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 3 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-clone.md
@@ -2,7 +2,7 @@ To create a local copy of a repository on the radicle network, we use the
`clone` command, followed by the identifier or *RID* of the repository:

```
-
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --scope followed
+
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found [..] potential seed(s).
✓ Target met: [..] seed(s)
modified crates/radicle-cli/examples/rad-fetch.md
@@ -10,7 +10,7 @@ have to update our seeding policy for the project.

```
$ rad seed rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --no-fetch
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
```

Now that the project is seeding we can fetch it and we will have it in
modified crates/radicle-cli/examples/rad-id-threshold.md
@@ -170,7 +170,7 @@ sync` and fetch his references:

``` ~bob
$ rad clone rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 2 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-init-no-seed.md
@@ -21,7 +21,7 @@ If we then seed it, it becomes advertised in our inventory:
```
$ rad seed rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
✓ Inventory updated with rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
-
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'all'
+
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'followed'
```
```
$ rad node inventory
modified crates/radicle-cli/examples/rad-init-private-clone-seed.md
@@ -30,7 +30,7 @@ $ rad inspect --identity
``` ~bob
$ rad ls --all --private
$ rad clone rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --seed z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✓ Target met: 1 preferred seed(s).
✓ Creating checkout in ./heartwood..
@@ -49,7 +49,7 @@ We can also use `rad seed` to seed and fetch without creating a checkout.

``` ~bob
$ rad seed rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy exists for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy exists for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
```
modified crates/radicle-cli/examples/rad-init-private-clone.md
@@ -6,7 +6,7 @@ $ rad ls
```
``` ~bob (fail)
$ rad clone rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu --seed z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --timeout 1
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
Fetching rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu from the network, found 1 potential seed(s).
✗ Target not met: could not fetch from [z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi], and required 1 more seed(s)
! Warning: Failed to fetch from 1 seed(s).
modified crates/radicle-cli/examples/rad-init-private-no-seed.md
@@ -28,7 +28,7 @@ We can decide to seed it later, so that others can fetch it from us, given
that they are part of the allow list:
```
$ rad seed rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu
-
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'all'
+
✓ Seeding policy updated for rad:z2ug5mwNKZB8KGpBDRTrWHAMbvHCu with scope 'followed'
```

But it still won't show up in our inventory, since it's private:
modified crates/radicle-cli/examples/rad-node.md
@@ -108,7 +108,7 @@ up in our inventory:
```
$ rad seed rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
✓ Inventory updated with rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'all'
+
✓ Seeding policy updated for rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji with scope 'followed'
$ rad node inventory
rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
```
modified crates/radicle-cli/examples/rad-patch-pull-update.md
@@ -22,7 +22,7 @@ To push changes, run `git push`.

``` ~bob
$ rad clone rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK
-
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'all'
+
✓ Seeding policy updated for rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK with scope 'followed'
Fetching rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
✓ Creating checkout in ./heartwood..
modified crates/radicle-cli/examples/rad-seed-many.md
@@ -4,10 +4,10 @@ is used):

```
$ rad seed rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm
-
✓ Seeding policy updated for rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW with scope 'all'
+
✓ Seeding policy updated for rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW with scope 'followed'
Fetching rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
-
✓ Seeding policy updated for rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm with scope 'all'
+
✓ Seeding policy updated for rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm with scope 'followed'
Fetching rad:z3zTnCfi6cVSZG8eCGn6AMDypgAPm from the network, found 1 potential seed(s).
✓ Target met: 1 seed(s)
```
added crates/radicle-cli/examples/rad-seed-policy-allow-no-scope.md
@@ -0,0 +1,7 @@
+
We want to ensure that a warning is printed when the `scope` field is missing in the `seedingPolicy`.
+

+
``` alice
+
$ rad node status
+
! Warning: Configuration option 'node.seedingPolicy.scope' is not set, and thus takes the value 'all' by default. The default value will change to 'followed' in a future release. Please edit your configuration file, and set it to one of ['all', 'followed'] explicitly.
+
[..]
+
```
modified crates/radicle-cli/examples/rad-sync-without-node.md
@@ -14,5 +14,5 @@ Note that seeding works fine without a running node:

``` ~alice
$ rad seed rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
-
✓ Seeding policy updated for rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 with scope 'all'
+
✓ Seeding policy updated for rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 with scope 'followed'
```
modified crates/radicle-cli/examples/rad-sync.md
@@ -107,7 +107,7 @@ It's also possible to receive an error if a repository is not found anywhere.

```
$ rad seed rad:z39mP9rQAaGmERfUMPULfPUi473tY --no-fetch
-
✓ Seeding policy updated for rad:z39mP9rQAaGmERfUMPULfPUi473tY with scope 'all'
+
✓ Seeding policy updated for rad:z39mP9rQAaGmERfUMPULfPUi473tY with scope 'followed'
```
``` (fail)
$ rad sync rad:z39mP9rQAaGmERfUMPULfPUi473tY
modified crates/radicle-cli/examples/rad-warn-old-nodes.md
@@ -38,8 +38,8 @@ $ rad debug
    "RAD_RNG_SEED": "0"
  },
  "warnings": [
-
    "Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please update your configuration.",
-
    "Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please update your configuration."
+
    "Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please edit your configuration file to use the new address.",
+
    "Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please edit your configuration file to use the new address."
  ]
}
```
@@ -48,8 +48,8 @@ Also, `rad node status` will warn us:

```
$ rad node status
-
! Warning: Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please update your configuration.
-
! Warning: Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please update your configuration.
+
! Warning: Value of configuration option `node.connect` at index 0 mentions node with address 'ash.radicle.garden:8776', which has been renamed to 'rosa.radicle.xyz:8776'. Please edit your configuration file to use the new address.
+
! Warning: Value of configuration option `preferredSeeds` at index 0 mentions node with address 'seed.radicle.garden:8776', which has been renamed to 'iris.radicle.xyz:8776'. Please edit your configuration file to use the new address.
Node is stopped.
To start it, run `rad node start`.
```
modified crates/radicle-cli/src/commands/clone/args.rs
@@ -62,7 +62,7 @@ pub struct Args {
    /// Follow scope
    #[arg(
        long,
-
        default_value_t = Scope::All,
+
        default_value_t = Scope::Followed,
        value_parser = terminal::args::ScopeParser
    )]
    pub(super) scope: Scope,
modified crates/radicle-cli/src/commands/debug.rs
@@ -131,7 +131,7 @@ fn stderr_of(bin: &str, args: &[&str]) -> anyhow::Result<String> {

fn collect_warnings(profile: Option<&Profile>) -> Vec<String> {
    match profile {
-
        Some(profile) => crate::warning::nodes_renamed(&profile.config),
+
        Some(profile) => crate::warning::config_warnings(&profile.config),
        None => vec!["No Radicle profile found.".to_string()],
    }
}
modified crates/radicle-cli/src/commands/node/control.rs
@@ -257,7 +257,7 @@ pub fn connect_many(
}

pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
-
    for warning in crate::warning::nodes_renamed(&profile.config) {
+
    for warning in crate::warning::config_warnings(&profile.config) {
        term::warning(warning);
    }

modified crates/radicle-cli/src/commands/seed.rs
@@ -84,7 +84,9 @@ pub fn seeding(profile: &Profile) -> anyhow::Result<()> {
                    .repository(rid)
                    .and_then(|repo| repo.project().map(|proj| proj.name().to_string()))
                    .unwrap_or_default();
-
                let scope = policy.scope().unwrap_or_default().to_string();
+
                let scope = policy
+
                    .scope()
+
                    .map_or(String::new(), |scope| scope.to_string());
                let policy = term::format::policy(&Policy::from(policy));

                t.push([
modified crates/radicle-cli/src/commands/seed/args.rs
@@ -49,7 +49,7 @@ pub struct Args {
    /// Peer follow scope for this repository
    #[arg(
        long,
-
        default_value_t = Scope::All,
+
        default_value_t = Scope::Followed,
        value_parser = terminal::args::ScopeParser
    )]
    pub(super) scope: Scope,
modified crates/radicle-cli/src/warning.rs
@@ -22,26 +22,51 @@ fn nodes_renamed_for_option(
    option: &'static str,
    iter: impl IntoIterator<Item = ConnectAddress>,
) -> Vec<String> {
-
    let mut warnings: Vec<String> = vec![];
-

-
    for (i, value) in iter.into_iter().enumerate() {
+
    iter.into_iter().enumerate().fold(Vec::new(), |mut warnings, (i, value)| {
        let old: Address = value.into();
        if let Some(new) = NODES_RENAMED.get(&old) {
            warnings.push(format!(
-
                "Value of configuration option `{option}` at index {i} mentions node with address '{old}', which has been renamed to '{new}'. Please update your configuration."
+
                "Value of configuration option `{option}` at index {i} mentions node with address '{old}', which has been renamed to '{new}'. Please edit your configuration file to use the new address."
            ));
        }
-
    }
-

-
    warnings
+
        warnings
+
    })
}

-
pub(crate) fn nodes_renamed(config: &Config) -> Vec<String> {
+
fn nodes_renamed(config: &Config) -> Vec<String> {
    let mut warnings = nodes_renamed_for_option("node.connect", config.node.connect.clone());
    warnings.extend(nodes_renamed_for_option(
        "preferredSeeds",
        config.preferred_seeds.clone(),
    ));
+

+
    warnings
+
}
+

+
fn implicit_seeding_policy_allow_scope(config: &Config) -> Vec<String> {
+
    use radicle::node::config::DefaultSeedingPolicy;
+
    use radicle::node::policy;
+

+
    let DefaultSeedingPolicy::Allow { scope } = config.node.seeding_policy else {
+
        return vec![];
+
    };
+
    if scope.is_implicit() {
+
        let scope = scope.into_inner();
+
        vec![format!(
+
                "Configuration option 'node.seedingPolicy.scope' is not set, and thus takes the value '{scope}' by default. The default value will change to '{}' in a future release. Please edit your configuration file, and set it to one of ['{}', '{}'] explicitly.",
+
                policy::Scope::Followed,
+
                policy::Scope::All,
+
                policy::Scope::Followed,
+
            )]
+
    } else {
+
        vec![]
+
    }
+
}
+

+
pub(crate) fn config_warnings(config: &Config) -> Vec<String> {
+
    let mut warnings = nodes_renamed(config);
+
    warnings.extend(implicit_seeding_policy_allow_scope(config));
+

    warnings
}

modified crates/radicle-cli/tests/commands.rs
@@ -572,6 +572,7 @@ fn rad_id_multi_delegate() {

    alice.handle.seed(acme, Scope::All).unwrap();
    bob.handle.follow(eve.id, None).unwrap();
+
    eve.handle.follow(bob.id, None).unwrap();
    alice.connect(&bob).converge([&bob]);
    eve.connect(&alice).converge([&alice]);

@@ -2060,17 +2061,19 @@ fn rad_remote() {
        .handle
        .follow(bob.id, Some(Alias::new("bob")))
        .unwrap();
+
    alice
+
        .handle
+
        .follow(eve.id, Some(Alias::new("eve")))
+
        .unwrap();

    bob.connect(&alice);
    bob.routes_to(&[(rid, alice.id)]);
    bob.fork(rid, bob.home.path()).unwrap();
-
    bob.announce(rid, 2, bob.home.path()).unwrap();
    alice.has_remote_of(&rid, &bob.id);

-
    eve.connect(&bob);
+
    eve.connect(&alice);
    eve.routes_to(&[(rid, alice.id)]);
    eve.fork(rid, eve.home.path()).unwrap();
-
    eve.announce(rid, 2, eve.home.path()).unwrap();
    alice.has_remote_of(&rid, &eve.id);

    test(
@@ -2869,3 +2872,24 @@ fn rad_workflow() {
    )
    .unwrap();
}
+

+
#[test]
+
fn rad_seed_policy_allow_no_scope() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node_with(Config {
+
        seeding_policy: DefaultSeedingPolicy::Allow {
+
            scope: node::config::Scope::implicit(),
+
        },
+
        ..Config::test(Alias::new("alice"))
+
    });
+

+
    let alice = alice.spawn();
+

+
    test(
+
        "examples/rad-seed-policy-allow-no-scope.md",
+
        environment.work(&alice),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+
}
modified crates/radicle-fetch/src/state.rs
@@ -87,6 +87,8 @@ pub mod error {
        Resolve(#[from] git::repository::error::Resolve),
        #[error(transparent)]
        Verified(#[from] radicle::identity::DocError),
+
        #[error("failed to verify `refs/rad/id`: {0}")]
+
        Graph(#[source] radicle::git::raw::Error),
    }
}

@@ -637,13 +639,48 @@ where
        self.handle.verified(head)
    }

+
    /// Resolve the verified [`Doc`], by choosing a `refs/rad/id` head to
+
    /// resolve from.
+
    ///
+
    /// There are two candidate namespaces:
+
    ///
+
    ///   1. Of the fetching node.
+
    ///   2. Of the node being fetched from.
+
    ///
+
    /// Both might be unset, in this case [`None`] is returned.
+
    ///
+
    /// If exactly one of the two is set, it is used.
+
    ///
+
    /// Otherwise, the ahead/behind relationship between the two candidates
+
    /// is checked, and (2.) is used if it is ahead of (1.).
    pub fn canonical(&self) -> Result<Option<Doc>, error::Canonical> {
        let tip = self.refname_to_id(refs::REFS_RAD_ID.clone())?;
        let cached_tip = self.canonical_rad_id();

-
        tip.or(cached_tip)
-
            .map(|tip| self.verified(tip).map_err(error::Canonical::from))
-
            .transpose()
+
        let oid = match (tip, cached_tip) {
+
            (None, None) => {
+
                return Ok(None);
+
            }
+
            (Some(oid), None) | (None, Some(oid)) => oid,
+
            (Some(repository), Some(cached)) => {
+
                let repo = self.handle.repository();
+
                match repo
+
                    .backend
+
                    .graph_ahead_behind(repository.into(), cached.into())
+
                {
+
                    Ok((ahead, behind)) => match (ahead, behind) {
+
                        (0, _) => cached,
+
                        _ => repository,
+
                    },
+
                    Err(err) if err.code() == radicle::git::raw::ErrorCode::NotFound => repository,
+
                    Err(err) => {
+
                        return Err(error::Canonical::Graph(err));
+
                    }
+
                }
+
            }
+
        };
+

+
        self.verified(oid).map(Some).map_err(error::Canonical::from)
    }

    pub fn load(&self, remote: &PublicKey) -> Result<Option<SignedRefsAt>, sigrefs::error::Load> {
modified crates/radicle-node/src/tests/e2e.rs
@@ -1,6 +1,8 @@
use std::{collections::HashSet, thread, time};

+
use radicle::cob;
use radicle::cob::Title;
+
use radicle_crypto::test::signer::MockSigner;
use test_log::test;

use radicle::git::raw::ErrorExt as _;
@@ -20,7 +22,7 @@ use crate::node::config::Limits;
use crate::node::{Config, ConnectOptions};
use crate::service;
use crate::storage::git::transport;
-
use crate::test::node::{converge, Node};
+
use crate::test::node::{converge, Node, NodeHandle};

mod config {
    use super::*;
@@ -1559,3 +1561,105 @@ fn test_fetch_emits_canonical_ref_update() {
        )
        .unwrap();
}
+

+
#[test]
+
fn test_non_fastforward_identity_doc() {
+
    use radicle::identity::Identity;
+

+
    let tmp = tempfile::tempdir().unwrap();
+

+
    let mut alice = Node::init(tmp.path(), Config::test(Alias::new("alice")));
+
    let bob = Node::init(tmp.path(), Config::test(Alias::new("bob")));
+
    let eve = Node::init(tmp.path(), Config::test(Alias::new("eve")));
+
    let alice_laptop = Node::init(tmp.path(), Config::test(Alias::new("alice-laptop")));
+

+
    let rid = alice.project("acme", "");
+

+
    let mut alice = alice.spawn();
+
    let mut alice_laptop = alice_laptop.spawn();
+
    let mut bob = bob.spawn();
+
    let mut eve = eve.spawn();
+

+
    let has_issue = |node: &NodeHandle<MockSigner>, issue: &cob::ObjectId| -> bool {
+
        let repo = node.storage.repository(rid).unwrap();
+
        repo.contains(**issue).unwrap()
+
    };
+

+
    alice.connect(&alice_laptop);
+
    alice.connect(&bob);
+
    alice.connect(&eve);
+
    eve.connect(&bob);
+
    eve.connect(&alice_laptop);
+

+
    // Due to permissive relaying, we need to lock down the scope for the RID.
+
    //
+
    // See: [`radicle-protocol::service::Service::relay()`] and
+
    //      [`radicle-protocol::service::Service::relay_announcement()`]
+
    alice.handle.seed(rid, Scope::Followed).unwrap();
+

+
    // Bob and Eve have the same state for the repository
+
    bob.handle.seed(rid, Scope::Followed).unwrap();
+
    bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
+

+
    alice_laptop.handle.seed(rid, Scope::All).unwrap();
+
    alice_laptop
+
        .handle
+
        .fetch(rid, alice.id, DEFAULT_TIMEOUT)
+
        .unwrap();
+
    // Alice pushes new references to her laptop
+
    let issue = alice_laptop.issue(
+
        rid,
+
        "Feature #1".parse().unwrap(),
+
        "Implementing new feature",
+
    );
+

+
    // Eve will fetch these references since her scope is "all"
+
    eve.handle.seed(rid, Scope::All).unwrap();
+
    eve.handle
+
        .fetch(rid, alice_laptop.id, DEFAULT_TIMEOUT)
+
        .unwrap();
+
    assert!(has_issue(&eve, &issue));
+

+
    // Alice updates the identity of the document to include her laptop
+
    let (prev, next) = {
+
        let repo = alice.storage.repository(rid).unwrap();
+
        let mut identity = Identity::load_mut(&repo).unwrap();
+
        let prev = identity.current;
+
        let doc = repo
+
            .identity_doc()
+
            .unwrap()
+
            .doc
+
            .with_edits(|raw| raw.delegate(alice_laptop.id.into()))
+
            .unwrap();
+
        let rev = identity
+
            .update(Title::new("Add Laptop").unwrap(), "", &doc, &alice.signer)
+
            .unwrap();
+
        repo.set_identity_head_to(rev).unwrap();
+
        (prev, rev)
+
    };
+

+
    assert!(!has_issue(&alice, &issue));
+

+
    // Bob fetches from Alice and we see the identity document was updated.
+
    //
+
    // Bob does not have the issue because Alice does not have the updates from
+
    // Alice's Laptop.
+
    let result = bob.handle.fetch(rid, alice.id, DEFAULT_TIMEOUT).unwrap();
+
    assert!(matches!(result, FetchResult::Success { .. }));
+
    assert!(!has_issue(&bob, &issue));
+
    let repo = bob.storage.repository(rid).unwrap();
+
    let identity = Identity::load_mut(&repo).unwrap();
+
    assert_eq!(identity.current, next);
+
    assert_eq!(identity.parent, Some(prev));
+

+
    // Bob fetches from Eve, the identity document should remain the same, but
+
    // since Bob now knows that Alice's Laptop is a delegate, the issue should
+
    // be fetched.
+
    bob.handle.fetch(rid, eve.id, DEFAULT_TIMEOUT).unwrap();
+
    assert!(matches!(result, FetchResult::Success { .. }));
+
    assert!(has_issue(&bob, &issue));
+
    let repo = bob.storage.repository(rid).unwrap();
+
    let identity = Identity::load_mut(&repo).unwrap();
+
    assert_eq!(identity.current, next);
+
    assert_eq!(identity.parent, Some(prev));
+
}
modified crates/radicle-protocol/src/service.rs
@@ -1597,9 +1597,18 @@ where
                    );
                    return Ok(None);
                };
-
                // Refs can be relayed by peers who don't have the data in storage,
-
                // therefore we only check whether we are connected to the *announcer*,
-
                // which is required by the protocol to only announce refs it has.
+
                // Ref announcements may be relayed by peers who don't have the
+
                // actual refs in storage, therefore we only check whether we
+
                // are connected to the *announcer*, which is required by the
+
                // protocol to only announce refs it has.
+
                //
+
                // TODO(Ade): Perhaps it makes sense to establish connections to
+
                // followed but unconnected peers. Consider:
+
                //   Connections: Alice ←→ Bob ←→ Eve
+
                //   Follows:     Alice ←→ Eve
+
                // Eve announces refs, and Bob relays these announcements to Alice.
+
                // Then, Alice might determine that Bob does not have Eve's refs,
+
                // and therefore connect directly to Eve in order to fetch.
                let Some(remote) = self.sessions.get(announcer).cloned() else {
                    trace!(
                        target: "service",
modified crates/radicle/Cargo.toml
@@ -72,6 +72,7 @@ pretty_assertions = { workspace = true }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle-cob = { workspace = true, features = ["stable-commit-ids", "test"] }
+
radicle-core = {workspace = true, features = ["qcheck"]}
radicle-crypto = { workspace = true, features = ["test"] }
radicle-git-metadata = { workspace = true }
tempfile = { workspace = true }
modified crates/radicle/src/node/config.rs
@@ -9,9 +9,11 @@ use serde::{Deserialize, Serialize};
use serde_json as json;

use crate::node;
-
use crate::node::policy::{Scope, SeedingPolicy};
+
use crate::node::policy::SeedingPolicy;
use crate::node::{Address, Alias, NodeId};

+
use super::policy;
+

/// Peer-to-peer protocol version.
pub type ProtocolVersion = u8;

@@ -364,13 +366,13 @@ pub enum AddressConfig {

/// Default seeding policy. Applies when no repository policies for the given repo are found.
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "default")]
+
#[serde(tag = "default", rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum DefaultSeedingPolicy {
    /// Allow seeding.
    Allow {
        /// Seeding scope.
-
        #[serde(default)]
+
        #[serde(skip_serializing_if = "Scope::is_implicit")]
        scope: Scope,
    },
    /// Block seeding.
@@ -378,6 +380,58 @@ pub enum DefaultSeedingPolicy {
    Block,
}

+
/// [`Scope`] provides a schema for [`policy::Scope`], where the inner scope is optional.
+
///
+
/// It is used in [`DefaultSeedingPolicy`] to allow for optionally setting the
+
/// scope in the [`DefaultSeedingPolicy::Allow`] variant.
+
/// This materializes as the `seedingPolicy.scope` configuration value, when
+
/// `"default": "allow"` is set.
+
///
+
/// [`Scope`] is introduced to allow migrating to a required value in a future
+
/// version (post v1.6.x).
+
#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
+
#[serde(transparent)]
+
pub struct Scope(Option<policy::Scope>);
+

+
impl Scope {
+
    /// Construct the implicit scope, where the default value,
+
    /// [`policy::Scope::All`], is chosen for the final scope value.
+
    pub fn implicit() -> Self {
+
        Self(None)
+
    }
+

+
    /// Construct the explicit scope, where the given [`policy::Scope`] is used.
+
    pub fn explicit(scope: policy::Scope) -> Self {
+
        Self(Some(scope))
+
    }
+

+
    /// Resolve this [`Scope`] to its [`policy::Scope`] value.
+
    ///
+
    /// If the scope is implicit, then [`policy::Scope::All`] is returned.
+
    pub fn into_inner(self) -> policy::Scope {
+
        self.0.unwrap_or(policy::Scope::All)
+
    }
+

+
    /// Returns `true` when the scope is implicit, i.e. no [`policy::Scope`] was
+
    /// given.
+
    pub fn is_implicit(&self) -> bool {
+
        self.0.is_none()
+
    }
+

+
    /// Construct the explicit [`Scope`] where the inner scope is
+
    /// [`policy::Scope::All`].
+
    fn all() -> Self {
+
        Self::explicit(policy::Scope::All)
+
    }
+

+
    /// Construct the explicit [`Scope`] where the inner scope is
+
    /// [`policy::Scope::Followed`].
+
    fn followed() -> Self {
+
        Self::explicit(policy::Scope::Followed)
+
    }
+
}
+

impl DefaultSeedingPolicy {
    /// Is this an "allow" policy.
    pub fn is_allow(&self) -> bool {
@@ -386,7 +440,16 @@ impl DefaultSeedingPolicy {

    /// Seed everything from anyone.
    pub fn permissive() -> Self {
-
        Self::Allow { scope: Scope::All }
+
        Self::Allow {
+
            scope: Scope::all(),
+
        }
+
    }
+

+
    /// Seed only delegate changes.
+
    pub fn followed() -> Self {
+
        Self::Allow {
+
            scope: Scope::followed(),
+
        }
    }
}

@@ -394,7 +457,9 @@ impl From<DefaultSeedingPolicy> for SeedingPolicy {
    fn from(policy: DefaultSeedingPolicy) -> Self {
        match policy {
            DefaultSeedingPolicy::Block => Self::Block,
-
            DefaultSeedingPolicy::Allow { scope } => Self::Allow { scope },
+
            DefaultSeedingPolicy::Allow { scope } => SeedingPolicy::Allow {
+
                scope: scope.into_inner(),
+
            },
        }
    }
}
@@ -646,6 +711,10 @@ wrapper!(
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
+
    use super::{DefaultSeedingPolicy, Scope};
+
    use crate::node::policy;
+
    use serde_json::json;
+

    #[test]
    fn partial() {
        use super::Config;
@@ -683,4 +752,85 @@ mod test {
        );
        assert_eq!(config.limits.connection.outbound.0, 1337);
    }
+

+
    #[test]
+
    fn deserialize_migrating_scope() {
+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow { scope: Scope(None) }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": null
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow { scope: Scope(None) }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": "all"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::All))
+
            }
+
        );
+

+
        let seeding_policy: DefaultSeedingPolicy = serde_json::from_value(json!({
+
            "default": "allow",
+
            "scope": "followed"
+
        }))
+
        .unwrap();
+

+
        assert_eq!(
+
            seeding_policy,
+
            DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::Followed))
+
            }
+
        )
+
    }
+

+
    #[test]
+
    fn serialize_migrating_scope() {
+
        assert_eq!(
+
            json!({
+
                "default": "allow"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow { scope: Scope(None) }).unwrap()
+
        );
+

+
        assert_eq!(
+
            json!({
+
                "default": "allow",
+
                "scope": "all"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::All))
+
            })
+
            .unwrap()
+
        );
+
        assert_eq!(
+
            json!({
+
                "default": "allow",
+
                "scope": "followed"
+
            }),
+
            serde_json::to_value(DefaultSeedingPolicy::Allow {
+
                scope: Scope(Some(policy::Scope::Followed))
+
            })
+
            .unwrap()
+
        );
+
    }
}
modified crates/radicle/src/profile/config.rs
@@ -192,7 +192,9 @@ impl Config {
            ) {
                log::warn!(target: "radicle", "Overwriting `seedingPolicy` configuration");
                cfg.node.seeding_policy = match policy {
-
                    Policy::Allow => DefaultSeedingPolicy::Allow { scope },
+
                    Policy::Allow => DefaultSeedingPolicy::Allow {
+
                        scope: node::config::Scope::explicit(scope),
+
                    },
                    Policy::Block => DefaultSeedingPolicy::Block,
                }
            }