rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwooda706c99b8f6c2b27048df78f4f3c05dfe2f27cba
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "712a995a282fd544b081ea02dae7ff246810a20c",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"title": "radicle/cob/identity: Rewrite Evaluation",
"state": {
"status": "open",
"conflicts": []
},
"before": "998ff91e2c25f1432de007c90fb447849ada37d2",
"after": "a706c99b8f6c2b27048df78f4f3c05dfe2f27cba",
"commits": [
"a706c99b8f6c2b27048df78f4f3c05dfe2f27cba",
"215fd674e3fbe46b0b846a8bffd23bf79091becb",
"f1384f758453af47e74fac9f8505d7d4b77f0270",
"c629fbc9a2b7fd1dff148d4173a8b85ade0a1767",
"cb91b330edfdd0f4bf1f3d285b84da9e827b0995",
"b43ef33fad4096f184487027d09f1784af7c2fcb",
"fe434ecfa49d0de4b1aac371baa3e446f4283299",
"72688c33fbc289b7529d619f3047367c214afe44",
"f6aaf1aaa7d0a8fcbeb327e366ed94629ad5596a",
"e0cf18ac1be9b84cf935dcc1d8d4048a60868886",
"35aeae3bad6e3f3e9eef362a2a8fbe484eecc606",
"73d499d29fa27f4f4296160bc98447f8efecec21",
"23ae5689e8a32860bfd457b66bffc0e7b2e3e902",
"9998627f6dc3c8c7aaa97dc27933fab211e5804d",
"47065e5cf0d1d7c82d3f37fff1e0145ae35e8098"
],
"target": "88bf2a9648750365d4565e32deae35b18808a391",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "712a995a282fd544b081ea02dae7ff246810a20c",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "### Use verdicts to count votes\n\nEvaluation of this COB is implemented under the assumption that\ndelegates accept at most one revision.\n\nThis can lead to issues, if some delegates create new revisions eagerly,\nwithout waiting for the others to vote on earlier revisions. As the\neager delegates start accepting newer and newer revisions, this shadows\ntheir acceptance of earlier revisions, which leads to failure to\nrecognize that these earlier revisions were actually accepted by a\nmajority.\n\nTo avoid such situations, remove `heads`, and always count the number of\n\"accept\" verdicts explicitly.\n\n### Rewrite Evaluation\n\nEvaluation of the COB `xyz.radicle.id` is rewritten to remove the\nvariant `State::Stale`.\n\nSince we require the history of accepted revisions to be linear, we may\nas well interpret all siblings of adopted revisions to be rejected.\nThis gives a nice symmetry between accepted and rejected revisions.\n\nIntroduce `State::Redacted`, as handling redacted revisions with their\nown state is easier to reason about compared to having to maintain\ninvariants for `revisions: BTreeMap<RevisionId, Option<Revision>>`.",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "7f92cfda0ac5572854bc564f60071f6e17371c62",
"timestamp": 1779442165
},
{
"id": "52e5ed2946f3c207f5de086af788253f76a1fb70",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Adds 3 additional tests. The last is failing.",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "22ce2ce70609009d3be6a6403aca07106383e262",
"timestamp": 1779457796
},
{
"id": "94d842c0f46d63e77be28cf0bef6557c5bb56b41",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Add fix and update existing tests",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "9d89a2e35c36290e320ffd18d341c0df9c333fa3",
"timestamp": 1779462080
},
{
"id": "528a25f8667d2cdb8388777d428e1a1924df5c1b",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Adds property tests for identity COB",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "6fe9842ed421f3cc5b1671c916d439d27c94bf59",
"timestamp": 1779902132
},
{
"id": "83fbe2b387974e4fa592ad1866f3dcc9cd201559",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Fixes threshold assumptions",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "b3beea8f837f67624d2b33bc6dd0a8c06be8a381",
"timestamp": 1779969863
},
{
"id": "fbc6da86326be94fde4906ea3311b831567a0144",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Removes more references to `threshold` and updates diagrams",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "8e46599111307315e6f51534dfbb28599516f1dd",
"timestamp": 1780054002
},
{
"id": "03e6eeeb7af3e71d0e3ea0fee99d79328e8bced5",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "- Add causal reasons to Rejected and Redacted states\n- Fix rejection threshold calculation\n- Fix sibling resolution for late-arriving forks",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "7d971f19bab96137e2032f9d6524335b4277e708",
"timestamp": 1780390774
},
{
"id": "39cbe88e23edf6352677f8cc8a8e3f143d008322",
"author": {
"id": "did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"alias": "fintohaps"
},
"description": "Rework Patch\n\nThe majority of this revision keeps the improved evaluation of the repository\nidentity. To make the changes more coherent, it squashes these improvements into\na single commit, re-describing the changes.\n\nThe test cases are then added as individual commits, followed by the property tests.\n\nThe additions I do provide in this revision are some extra test cases, and\ncatching a bug in the adoption logic that allowed rescinded delegates to count\ntheir votes in child revisions.",
"base": "998ff91e2c25f1432de007c90fb447849ada37d2",
"oid": "a706c99b8f6c2b27048df78f4f3c05dfe2f27cba",
"timestamp": 1781281164
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "e4b9b571-597f-41f7-b8e9-6ca773157859"
},
"info_url": "https://cci.rad.levitte.org//e4b9b571-597f-41f7-b8e9-6ca773157859.html"
}
Started at: 2026-06-12 18:19:26.581567+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW added
✓ Remote-tracking branch cloudhead@z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW/master created for z6MktaNvN1KVFMkSRAiN4qK5yvX1zuEEaseeX5sffhzPZRZW
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz added
✓ Remote-tracking branch erikli@z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz/master created for z6MkgFq6z5fkF2hioLLSNu1zP2qEL1aHXHZzGH1FLFGAnBGz
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/e4b9b571-597f-41f7-b8e9-6ca773157859/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 180 issues · 40 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 712a995a282fd544b081ea02dae7ff246810a20c
✓ Switched to branch patch/712a995 at revision 712a995
✓ Branch patch/712a995 setup to track rad/patches/712a995a282fd544b081ea02dae7ff246810a20c
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout a706c99b8f6c2b27048df78f4f3c05dfe2f27cba
HEAD is now at a706c99b radicle/cob/identity: Test previously accepted revision cannot be redacted
Exit code: 0
$ rad patch show 712a995a282fd544b081ea02dae7ff246810a20c -p
╭──────────────────────────────────────────────────────────────────────────────────╮
│ Title radicle/cob/identity: Rewrite Evaluation │
│ Patch 712a995a282fd544b081ea02dae7ff246810a20c │
│ Author lorenz z6MkkPv…WX5sTEz │
│ Head 7f92cfda0ac5572854bc564f60071f6e17371c62 │
│ Base 998ff91e2c25f1432de007c90fb447849ada37d2 │
│ Branches patch/712a995 │
│ Commits ahead 3, behind 38 │
│ Status open │
│ │
│ ### Use verdicts to count votes │
│ │
│ Evaluation of this COB is implemented under the assumption that │
│ delegates accept at most one revision. │
│ │
│ This can lead to issues, if some delegates create new revisions eagerly, │
│ without waiting for the others to vote on earlier revisions. As the │
│ eager delegates start accepting newer and newer revisions, this shadows │
│ their acceptance of earlier revisions, which leads to failure to │
│ recognize that these earlier revisions were actually accepted by a │
│ majority. │
│ │
│ To avoid such situations, remove `heads`, and always count the number of │
│ "accept" verdicts explicitly. │
│ │
│ ### Rewrite Evaluation │
│ │
│ Evaluation of the COB `xyz.radicle.id` is rewritten to remove the │
│ variant `State::Stale`. │
│ │
│ Since we require the history of accepted revisions to be linear, we may │
│ as well interpret all siblings of adopted revisions to be rejected. │
│ This gives a nice symmetry between accepted and rejected revisions. │
│ │
│ Introduce `State::Redacted`, as handling redacted revisions with their │
│ own state is easier to reason about compared to having to maintain │
│ invariants for `revisions: BTreeMap<RevisionId, Option<Revision>>`. │
├──────────────────────────────────────────────────────────────────────────────────┤
│ 7f92cfd radicle/cob/identity: Rewrite Evaluation │
│ d5f6dae radicle/cob/identity: Use verdicts to count votes │
│ 2762c2d radicle/cob/identity: Strengthen test assumptions │
├──────────────────────────────────────────────────────────────────────────────────┤
│ ● Revision 712a995 @ 998ff91..7f92cfd by lorenz z6MkkPv…WX5sTEz 3 weeks ago │
│ ↑ Revision 52e5ed2 @ 998ff91..22ce2ce by ade z6MkwGo…yS2aagA 3 weeks ago │
│ ↑ Revision 94d842c @ 998ff91..9d89a2e by ade z6MkwGo…yS2aagA 3 weeks ago │
│ ↑ Revision 528a25f @ 998ff91..6fe9842 by ade z6MkwGo…yS2aagA 2 weeks ago │
│ ↑ Revision 83fbe2b @ 998ff91..b3beea8 by ade z6MkwGo…yS2aagA 2 weeks ago │
│ ↑ Revision fbc6da8 @ 998ff91..8e46599 by ade z6MkwGo…yS2aagA 2 weeks ago │
│ ↑ Revision 03e6eee @ 998ff91..7d971f1 by ade z6MkwGo…yS2aagA 1 week ago │
│ ↑ Revision 39cbe88 @ 998ff91..a706c99 by fintohaps z6Mkire…SQZ3voM 4 seconds ago │
╰──────────────────────────────────────────────────────────────────────────────────╯
commit 7f92cfda0ac5572854bc564f60071f6e17371c62
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Mar 30 17:57:28 2026 +0200
radicle/cob/identity: Rewrite Evaluation
Evaluation of the COB `xyz.radicle.id` is rewritten to remove the
variant `State::Stale`.
Since we require the history of accepted revisions to be linear, we may
as well interpret all siblings of adopted revisions to be rejected.
This gives a nice symmetry between accepted and rejected revisions.
Introduce `State::Redacted`, as handling redacted revisions with their
own state is easier to reason about compared to having to maintain
invariants for `revisions: BTreeMap<RevisionId, Option<Revision>>`.
diff --git a/crates/radicle-cli/examples/rad-id-conflict.md b/crates/radicle-cli/examples/rad-id-conflict.md
index 495dc6f70..b43fe5915 100644
--- a/crates/radicle-cli/examples/rad-id-conflict.md
+++ b/crates/radicle-cli/examples/rad-id-conflict.md
@@ -53,7 +53,7 @@ $ rad id list
│ ● ID Title Author Status Created Parent │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ● 89b2623 Edit project name bob z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk accepted now 0ca42d3 │
-│ ● 12d7300 Edit project name alice (you) stale now 0ca42d3 │
+│ ● 12d7300 Edit project name alice (you) rejected now 0ca42d3 │
│ ● 0ca42d3 Add Bob alice (you) accepted now 0656c21 │
│ ● 0656c21 Initial revision alice (you) accepted now none │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -70,9 +70,9 @@ Fetching rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from the network, found 1 potential s
```
``` ~bob (fail)
$ rad id accept 12d7300 -q
-✗ Error: cannot vote on revision that is stale
+✗ Error: cannot vote on revision that is rejected
$ rad id reject 12d7300 -q
-✗ Error: cannot vote on revision that is stale
+✗ Error: cannot vote on revision that is rejected
```
``` ~bob
$ rad id show 12d7300
@@ -82,7 +82,7 @@ $ rad id show 12d7300
│ Parent 0ca42d376bd566631083c8913cf86bec722da392 │
│ Blob e93aa3e3c5c448bacd3537a81daf1437eccd046a │
│ Author did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi │
-│ State stale │
+│ State rejected │
│ Quorum no │
├────────────────────────────────────────────────────────────────────────┤
│ ✓ did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi alice │
diff --git a/crates/radicle-cli/src/commands/id.rs b/crates/radicle-cli/src/commands/id.rs
index c7b2b0356..656ea7f38 100644
--- a/crates/radicle-cli/src/commands/id.rs
+++ b/crates/radicle-cli/src/commands/id.rs
@@ -223,7 +223,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
identity::State::Active => term::format::tertiary("●"),
identity::State::Accepted => term::format::positive("●"),
identity::State::Rejected => term::format::negative("●"),
- identity::State::Stale => term::format::dim("●"),
+ identity::State::Redacted => continue,
}
.into();
let state = r.state.to_string().into();
@@ -288,6 +288,7 @@ fn get<'a>(
let id = revision.resolve(&repo.backend)?;
let revision = identity
.revision(&id)
+ .filter(|revision| revision.state != identity::State::Redacted)
.ok_or(anyhow!("revision `{id}` not found"))?;
Ok(revision)
diff --git a/crates/radicle-cli/src/terminal/format.rs b/crates/radicle-cli/src/terminal/format.rs
index 78a42390c..387ddad37 100644
--- a/crates/radicle-cli/src/terminal/format.rs
+++ b/crates/radicle-cli/src/terminal/format.rs
@@ -405,8 +405,7 @@ pub mod identity {
match s {
State::Active => term::format::tertiary(s.to_string()),
State::Accepted => term::format::positive(s.to_string()),
- State::Rejected => term::format::negative(s.to_string()),
- State::Stale => term::format::dim(s.to_string()),
+ State::Rejected | State::Redacted => term::format::negative(s.to_string()),
}
}
}
diff --git a/crates/radicle/src/cob/identity.rs b/crates/radicle/src/cob/identity.rs
index fb44cd500..d019174d7 100644
--- a/crates/radicle/src/cob/identity.rs
+++ b/crates/radicle/src/cob/identity.rs
@@ -179,7 +179,7 @@ pub struct Identity {
pub root: RevisionId,
/// Revisions.
- revisions: BTreeMap<RevisionId, Option<Revision>>,
+ revisions: BTreeMap<RevisionId, Revision>,
/// Timeline of events.
timeline: Vec<EntryId>,
}
@@ -206,7 +206,7 @@ impl Identity {
id: revision.blob.into(),
root,
current: root,
- revisions: BTreeMap::from_iter([(root, Some(revision))]),
+ revisions: BTreeMap::from_iter([(root, revision)]),
timeline: vec![root],
}
}
@@ -331,14 +331,16 @@ impl Identity {
/// A specific [`Revision`], that may be redacted.
pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> {
- self.revisions.get(revision).and_then(|r| r.as_ref())
+ self.revisions.get(revision)
}
/// All the [`Revision`]s that have not been redacted.
pub fn revisions(&self) -> impl DoubleEndedIterator<Item = &Revision> {
- self.timeline
- .iter()
- .filter_map(|id| self.revisions.get(id).and_then(|o| o.as_ref()))
+ self.timeline.iter().filter_map(|id| {
+ self.revisions
+ .get(id)
+ .filter(|revision| revision.state != State::Redacted)
+ })
}
pub fn latest_by(&self, who: &Did) -> Option<&Revision> {
@@ -427,12 +429,8 @@ impl store::Cob for Identity {
// This particular error is returned when there is a mismatch between the expected
// and the actual state of a revision, which can happen concurrently. Therefore
// if there are other concurrent ops, it is not fatal and we simply ignore it.
- Err(ApplyError::UnexpectedState) => {
- if concurrent.is_empty() {
- return Err(ApplyError::UnexpectedState);
- }
- }
- // It's not a user error if the revision happens to be redacted by
+ Err(ApplyError::UnexpectedState) if !concurrent.is_empty() => {}
+ // It is not a user error if the revision happens to be redacted by
// the time this action is processed.
Err(ApplyError::Redacted) => {}
Err(other) => return Err(other),
@@ -452,8 +450,7 @@ impl Identity {
/// * There is only ever one accepted revision; this is the "current" revision.
/// * There can be zero or more active revisions, up to the number of delegates.
/// * An active revision is one that can be "voted" on.
- /// * An active revision always has the current revision as parent.
- /// * Only the active revision can be accepted, rejected or edited.
+ /// * Only an active revision can be accepted, rejected or edited.
fn action<R: ReadRepository>(
&mut self,
action: Action,
@@ -474,31 +471,60 @@ impl Identity {
revision,
signature,
} => {
- let id = revision;
- let Some(revision) = lookup::revision_mut(&mut self.revisions, &id)? else {
- return Err(ApplyError::Redacted);
- };
- if !revision.is_active() {
- // You can't vote on an inactive revision.
- return Err(ApplyError::UnexpectedState);
+ let revision = self.revision_mut(&revision)?;
+ match &revision.state {
+ State::Accepted => {
+ log::trace!(
+ "Skipping acceptance of revision {} by {did} because it already is accepted.",
+ revision.id
+ );
+ }
+ State::Rejected => {
+ log::debug!(
+ "Skipping acceptance of revision {} by {did} because it already is rejected.",
+ revision.id
+ );
+ }
+ State::Active => {
+ log::trace!(
+ "Applying acceptance of active revision {} by {did}.",
+ revision.id
+ );
+ revision.accept(author, signature, ¤t)?;
+ let id = revision.id;
+ self.adopt(id);
+ }
+ State::Redacted => {
+ return Err(ApplyError::Redacted);
+ }
}
- assert_eq!(revision.parent, Some(current.id));
-
- revision.accept(author, signature, ¤t)?;
-
- self.adopt(id);
}
Action::RevisionReject { revision } => {
- let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
- return Err(ApplyError::Redacted);
- };
- if !revision.is_active() {
- // You can't vote on an inactive revision.
- return Err(ApplyError::UnexpectedState);
+ let revision = self.revision_mut(&revision)?;
+ match &revision.state {
+ State::Accepted => {
+ log::debug!(
+ "Skipping rejection of revision {} by {did} because it already is accepted.",
+ revision.id
+ );
+ }
+ State::Rejected => {
+ log::trace!(
+ "Skipping rejection of revision {} by {did} because it already is rejected.",
+ revision.id
+ );
+ }
+ State::Active => {
+ log::trace!(
+ "Applying rejection of active revision {} by {did}.",
+ revision.id
+ );
+ revision.reject(author)?;
+ }
+ State::Redacted => {
+ return Err(ApplyError::Redacted);
+ }
}
- assert_eq!(revision.parent, Some(current.id));
-
- revision.reject(author)?;
}
Action::RevisionEdit {
title,
@@ -508,15 +534,20 @@ impl Identity {
if revision == self.current {
return Err(ApplyError::NotAuthorized);
}
- let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else {
- return Err(ApplyError::Redacted);
- };
+ let revision = self.revision_mut(&revision)?;
if !revision.is_active() {
- // You can't edit an inactive revision.
+ log::debug!(
+ "Cannot edit revision {} because it is not active.",
+ revision.id
+ );
return Err(ApplyError::UnexpectedState);
}
if revision.author.public_key() != &author {
- // Can't edit someone else's revision.
+ log::debug!(
+ "{} cannot edit revision created by {}.",
+ author,
+ revision.author.public_key()
+ );
// Since the author never changes, we can safely mark this as invalid.
return Err(ApplyError::NotAuthorized);
}
@@ -527,25 +558,26 @@ impl Identity {
}
Action::RevisionRedact { revision } => {
if revision == self.current {
- // Can't redact the current revision.
- return Err(ApplyError::UnexpectedState);
+ log::debug!("Cannot redact current revision {revision}.");
+ return Ok(());
}
- if let Some(revision) = self.revisions.get_mut(&revision) {
- if let Some(r) = revision {
- if r.is_accepted() {
- // You can't redact an accepted revision.
- return Err(ApplyError::UnexpectedState);
- }
- if r.author.public_key() != &author {
- // Can't redact someone else's revision.
- // Since the author never changes, we can safely mark this as invalid.
- return Err(ApplyError::NotAuthorized);
- }
- *revision = None;
- }
- } else {
- return Err(ApplyError::Missing(revision));
+ let revision = self.revision_mut(&revision)?;
+
+ if !revision.is_active() {
+ log::debug!("Cannot redact inactive revision {}.", revision.id);
+ return Ok(());
}
+ if revision.author.public_key() != &author {
+ log::debug!(
+ "{} cannot redact revision created by {}.",
+ author,
+ revision.author.public_key()
+ );
+ // Since the author never changes, we can safely mark this as invalid.
+ return Err(ApplyError::NotAuthorized);
+ }
+ log::debug!("Redacting revision {}.", revision.id);
+ revision.state = State::Redacted;
}
Action::Revision {
title,
@@ -554,7 +586,7 @@ impl Identity {
signature,
parent,
} => {
- debug_assert!(!self.revisions.contains_key(&entry));
+ debug_assert_eq!(self.revisions.get(&entry), None, "revision visited twice");
let doc = repo.blob(blob)?;
let doc = Doc::from_blob(&doc)?;
@@ -562,26 +594,16 @@ impl Identity {
let Some(parent) = parent else {
return Err(ApplyError::MissingParent);
};
- let Some(parent) = lookup::revision(&self.revisions, &parent)? else {
- return Err(ApplyError::Redacted);
- };
- // If the parent of this revision is no longer the current document, this
- // revision can be marked as outdated.
- let state = if parent.id == current.id {
- // If the revision is not outdated, we expect it to make a change to the
- // current version.
- if doc == parent.doc {
- return Err(ApplyError::DocUnchanged);
- }
- State::Active
- } else {
- State::Stale
- };
-
+ let parent = self.revision_mut(&parent)?;
+ // We expect the revision to make a change compared to its parent.
+ if doc == parent.doc {
+ return Err(ApplyError::DocUnchanged);
+ }
// Verify signature over new blob, using trusted delegates.
if parent.verify_signature(&author, &signature, blob).is_err() {
return Err(ApplyError::InvalidSignature(author, blob));
}
+
let revision = Revision::new(
entry,
title,
@@ -589,54 +611,118 @@ impl Identity {
author.into(),
blob,
doc,
- state,
+ State::Active,
signature,
Some(parent.id),
timestamp,
);
let id = revision.id;
- self.revisions.insert(id, Some(revision));
-
- if state == State::Active {
- self.adopt(id);
- }
+ self.revisions.insert(id, revision);
+ self.adopt(id);
}
}
Ok(())
}
- /// Try to adopt a revision as the current one.
+ /// Try to adopt an active revision as the current one.
+ ///
+ /// # Panics
+ ///
+ /// If the revision with the given ID is not active or lookup from
+ /// `self.revisions` returns a revision with a different ID.
+ /// If the parent revision of the revision with given ID does not exist.
fn adopt(&mut self, id: RevisionId) {
if self.current == id {
return;
}
- let votes = self
- .revision(&id)
- .map(|revision| revision.accepted().count())
- .unwrap_or_default();
- if self.is_majority(votes) {
- self.current = id;
- self.current_mut().state = State::Accepted;
-
- // Void all other active revisions.
- for r in self
- .revisions
- .iter_mut()
- .filter_map(|(_, r)| r.as_mut())
- .filter(|r| r.state == State::Active)
- {
- r.state = State::Stale;
+
+ let Some(candidate) = self.revision(&id) else {
+ return;
+ };
+
+ // Invariant of this module. The key and ID of value in
+ // `self.revisions` must be equal.
+ assert_eq!(candidate.id, id);
+
+ // Invariant of this module. This function should only be called on
+ // active revisions.
+ assert_eq!(candidate.state, State::Active);
+
+ let parent = candidate.parent.expect("revision must have parent");
+
+ if parent != self.current {
+ log::debug!(
+ "Cannot adopt revision {} because its parent {} is not the current revision {}.",
+ id,
+ parent,
+ self.current
+ );
+ return;
+ }
+
+ let votes = candidate.accepted().count();
+
+ if !self.is_majority(votes) {
+ log::trace!(
+ "Revision {} has {} votes, but needs {} to be adopted.",
+ id,
+ votes,
+ self.majority()
+ );
+ return;
+ }
+
+ // Reject all sibling revisions and their children.
+ let mut reject = Vec::new();
+
+ loop {
+ let mut found = false;
+ for (revision_id, revision) in self.revisions.iter() {
+ let Some(revision_parent) = revision.parent else {
+ continue;
+ };
+
+ if (revision_parent == parent || reject.contains(&revision_parent))
+ && !reject.contains(revision_id)
+ && revision.state == State::Active
+ && revision_id != &id
+ {
+ log::debug!("Adoption of {} causes {} to be rejected.", id, revision_id);
+
+ found = true;
+ reject.push(*revision_id);
+ }
+ }
+ if !found {
+ break;
}
}
+
+ for id in reject {
+ if let Some(revision) = self.revisions.get_mut(&id) {
+ revision.state = State::Rejected;
+ }
+ }
+
+ self.current = id;
+ self.current_mut().state = State::Accepted;
}
/// A specific [`Revision`], mutably.
- fn revision_mut(&mut self, revision: &RevisionId) -> Option<&mut Revision> {
- self.revisions.get_mut(revision).and_then(|r| r.as_mut())
+ ///
+ /// # Errors
+ ///
+ /// Returns `ApplyError::Missing` if the revision is not found.
+ fn revision_mut(&mut self, id: &RevisionId) -> Result<&mut Revision, ApplyError> {
+ self.revisions.get_mut(id).ok_or(ApplyError::Missing(*id))
}
/// The current revision, mutably.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the current revision is not found.
fn current_mut(&mut self) -> &mut Revision {
let current = self.current;
self.revision_mut(¤t)
@@ -680,18 +766,29 @@ pub enum Verdict {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum State {
- /// The revision is actively being voted on. From here, it can go into any of the
- /// other states.
+ /// The initial state of any revision.
+ ///
+ /// If a revision receives a majority of accepting votes, it is adopted and
+ /// transitions to [`Self::Accepted`]. Also, all its sibling revisions
+ /// transition to [`Self::Rejected`].
+ ///
+ /// If a revisions receives a majority of rejecting votes,
+ /// it transitions to [`Self::Rejected`]. This has no impact on sibling
+ /// revisions.
+ ///
+ /// If a revision is redacted (this can only be done by its authoring
+ /// delegate), it transitions to [`Self::Redacted`]. From there, no further
+ /// state transitions are possible. This can be viewed as a form of
+ /// withdrawal of the revision.
Active,
- /// The revision has been accepted by a majority of delegates. Once accepted,
- /// a revision doesn't change state.
+ /// The revision was accepted by a majority of delegates.
+ /// Accepted revisions cannot be redacted.
Accepted,
- /// The revision was rejected by a majority of delegates. Once rejected,
- /// a revision doesn't change state.
+ /// The revision was rejected by a majority of delegates, or
+ /// a sibling revision was accepted by a majority of delegates.
Rejected,
- /// The revision was active, but has been replaced by another revision,
- /// and is now outdated. Once stale, a revision doesn't change state.
- Stale,
+ /// The author decided to redact/withdraw the revision.
+ Redacted,
}
impl std::fmt::Display for State {
@@ -700,7 +797,7 @@ impl std::fmt::Display for State {
Self::Active => write!(f, "active"),
Self::Accepted => write!(f, "accepted"),
Self::Rejected => write!(f, "rejected"),
- Self::Stale => write!(f, "stale"),
+ Self::Redacted => write!(f, "redacted"),
}
}
}
@@ -840,9 +937,8 @@ impl Revision {
if self.verdicts.insert(key, Verdict::Reject).is_some() {
return Err(ApplyError::DuplicateVerdict);
}
- // Mark as rejected if it's impossible for this revision to be accepted
- // with the current delegate set. Note that if the delegate set changes,
- // this proposal will be marked as `stale` anyway.
+ // Mark as rejected if it is impossible for this revision to be accepted
+ // with the current delegate set.
if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() {
self.state = State::Rejected;
}
@@ -1030,36 +1126,6 @@ impl<Repo, Signer> Deref for IdentityMut<'_, '_, Repo, Signer> {
}
}
-mod lookup {
- use super::*;
-
- pub fn revision_mut<'a>(
- revisions: &'a mut BTreeMap<RevisionId, Option<Revision>>,
- revision: &RevisionId,
- ) -> Result<Option<&'a mut Revision>, ApplyError> {
- match revisions.get_mut(revision) {
- Some(Some(revision)) => Ok(Some(revision)),
- // Redacted.
- Some(None) => Ok(None),
- // Missing. Causal error.
- None => Err(ApplyError::Missing(*revision)),
- }
- }
-
- pub fn revision<'a>(
- revisions: &'a BTreeMap<RevisionId, Option<Revision>>,
- revision: &RevisionId,
- ) -> Result<Option<&'a Revision>, ApplyError> {
- match revisions.get(revision) {
- Some(Some(revision)) => Ok(Some(revision)),
- // Redacted.
- Some(None) => Ok(None),
- // Missing. Causal error.
- None => Err(ApplyError::Missing(*revision)),
- }
- }
-}
-
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
@@ -1295,7 +1361,7 @@ mod test {
assert_eq!(bob_identity.current, a2);
assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted);
assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted);
- assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Stale);
+ assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Rejected);
}
#[test]
@@ -1341,7 +1407,7 @@ mod test {
bob_identity.reload().unwrap();
assert_eq!(bob_identity.timeline, vec![a0, a1, a2, a3, b1]);
- assert_eq!(bob_identity.revision(&a2), None);
+ assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Redacted);
assert_eq!(bob_identity.current, a1);
}
@@ -1561,10 +1627,9 @@ mod test {
eve_identity.reload().unwrap();
assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]);
- // Her revision is there, although stale, since another revision was accepted since.
- // However, it wasn't pruned, even though rejecting an accepted revision is an error.
+ // Her revision is there and active, since her revision is a child of the accepted one.
let e2 = eve_identity.revision(&e2).unwrap();
- assert_eq!(e2.state, State::Stale);
+ assert_eq!(e2.state, State::Active);
assert!(eve_identity.revision(&a2).unwrap().is_accepted());
}
@@ -1641,7 +1706,7 @@ mod test {
eve_identity.reload().unwrap();
assert_eq!(eve_identity.timeline, vec![a0, a1, b1, e1, a2]);
- assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Stale);
+ assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Rejected);
}
#[test]
commit d5f6dae7d019c4b7ceb0c92688936762344950d6
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Mon Mar 30 17:57:28 2026 +0200
radicle/cob/identity: Use verdicts to count votes
Evaluation of this COB is implemented under the assumption that
delegates accept at most one revision.
This can lead to issues, if some delegates create new revisions eagerly,
without waiting for the others to vote on earlier revisions. As the
eager delegates start accepting newer and newer revisions, this shadows
their acceptance of earlier revisions, which leads to failure to
recognize that these earlier revisions were actually accepted by a
majority.
To avoid such situations, remove `heads`, and always count the number of
"accept" verdicts explicitly.
diff --git a/crates/radicle/src/cob/identity.rs b/crates/radicle/src/cob/identity.rs
index bf037e90b..fb44cd500 100644
--- a/crates/radicle/src/cob/identity.rs
+++ b/crates/radicle/src/cob/identity.rs
@@ -177,9 +177,6 @@ pub struct Identity {
pub current: RevisionId,
/// The initial revision of the document.
pub root: RevisionId,
- /// The latest revision that each delegate has accepted.
- /// Delegates can only accept one revision at a time.
- pub heads: BTreeMap<Did, RevisionId>,
/// Revisions.
revisions: BTreeMap<RevisionId, Option<Revision>>,
@@ -209,12 +206,6 @@ impl Identity {
id: revision.blob.into(),
root,
current: root,
- heads: revision
- .delegates()
- .iter()
- .copied()
- .map(|did| (did, root))
- .collect(),
revisions: BTreeMap::from_iter([(root, Some(revision))]),
timeline: vec![root],
}
@@ -493,7 +484,6 @@ impl Identity {
}
assert_eq!(revision.parent, Some(current.id));
- self.heads.insert(author.into(), id);
revision.accept(author, signature, ¤t)?;
self.adopt(id);
@@ -606,7 +596,6 @@ impl Identity {
);
let id = revision.id;
- self.heads.insert(author.into(), id);
self.revisions.insert(id, Some(revision));
if state == State::Active {
@@ -623,10 +612,9 @@ impl Identity {
return;
}
let votes = self
- .heads
- .values()
- .filter(|revision| **revision == id)
- .count();
+ .revision(&id)
+ .map(|revision| revision.accepted().count())
+ .unwrap_or_default();
if self.is_majority(votes) {
self.current = id;
self.current_mut().state = State::Accepted;
@@ -1357,6 +1345,73 @@ mod test {
assert_eq!(bob_identity.current, a1);
}
+ #[test]
+ fn eager_staleness() {
+ let network = Network::default();
+ let alice = &network.alice;
+ let bob = &network.bob;
+ let eve = &network.eve;
+
+ let mut alice_identity = Identity::load_mut(&*alice.repo, &alice.signer).unwrap();
+ let mut alice_doc = alice_identity.doc().clone().edit();
+
+ alice_doc.delegate(bob.signer.public_key().into());
+ alice_doc.delegate(eve.signer.public_key().into());
+
+ let a1 = alice_identity // Change description to change traversal order.
+ .update(
+ cob::Title::new("Add Bob and Eve").unwrap(),
+ "Eh#!",
+ &alice_doc.clone().verified().unwrap(),
+ )
+ .unwrap();
+
+ bob.repo.fetch(alice);
+ eve.repo.fetch(alice);
+
+ let mut bob_identity = Identity::load_mut(&*bob.repo, &bob.signer).unwrap();
+ assert_eq!(bob_identity.current, a1);
+
+ let mut bob_doc = bob_identity.doc().clone().edit();
+ bob_doc.visibility = Visibility::private([]);
+ let bob_doc = bob_doc.verified().unwrap();
+ let b1 = cob::stable::with_advanced_timestamp(|| {
+ bob_identity
+ .update(
+ cob::Title::new("Make private").unwrap(),
+ "",
+ &bob_doc.clone(),
+ )
+ .unwrap()
+ });
+
+ // Now, bob is very eager to change the description of the repository,
+ // and does not wait for Eve to accept `b1`.
+
+ let bob_doc = bob_doc.clone().edit();
+ bob_doc
+ .project()
+ .unwrap()
+ .update(None, Some("New Description".to_string()), None)
+ .unwrap();
+ let bob_doc = bob_doc.verified().unwrap();
+ let _b2 = cob::stable::with_advanced_timestamp(|| {
+ bob_identity
+ .update(cob::Title::new("Change Description").unwrap(), "", &bob_doc)
+ .unwrap()
+ });
+
+ eve.repo.fetch(bob);
+
+ let mut eve_identity = Identity::load_mut(&*eve.repo, &eve.signer).unwrap();
+
+ // Eve is now ready to accept Bob's revision `b1`, but Bob's newest is `_b2`.
+ cob::stable::with_advanced_timestamp(|| eve_identity.accept(&b1).unwrap());
+
+ // Now that Eve has accepted `b1`, it should be accepted and thus current.
+ assert_eq!(eve_identity.current, b1);
+ }
+
#[test]
fn test_identity_remove_delegate_concurrent() {
let network = Network::default();
commit 2762c2d7aa6ef61a796292cff931b829a3989901
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
Date: Tue Mar 31 23:35:59 2026 +0200
radicle/cob/identity: Strengthen test assumptions
Not only assert that the revision is active, but
also its parent.
diff --git a/crates/radicle/src/cob/identity.rs b/crates/radicle/src/cob/identity.rs
index 31d8aab28..bf037e90b 100644
--- a/crates/radicle/src/cob/identity.rs
+++ b/crates/radicle/src/cob/identity.rs
@@ -1482,7 +1482,10 @@ mod test {
&eve_doc.verified().unwrap(),
)
.unwrap();
- assert!(eve_identity.revision(&e2).unwrap().is_active());
+
+ let eve_revision = eve_identity.revision(&e2).unwrap();
+ assert_eq!(eve_revision.state, State::Active);
+ assert_eq!(eve_revision.parent, Some(a1));
// e2 (Propose "Change visibility") 1/2
// |
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name e4b9b571-597f-41f7-b8e9-6ca773157859 -v /opt/radcis/ci.rad.levitte.org/cci/state/e4b9b571-597f-41f7-b8e9-6ca773157859/s:/e4b9b571-597f-41f7-b8e9-6ca773157859/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/e4b9b571-597f-41f7-b8e9-6ca773157859/w:/e4b9b571-597f-41f7-b8e9-6ca773157859/w -w /e4b9b571-597f-41f7-b8e9-6ca773157859/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /e4b9b571-597f-41f7-b8e9-6ca773157859/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.95-x86_64-unknown-linux-gnu'
info: latest update on 2026-04-16, rust version 1.95.0 (59807616e 2026-04-14)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
+ rustc --version
rustc 1.95.0 (59807616e 2026-04-14)
+ cargo fmt --check
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity/test/property.rs:50:
/// rescinded.
///
/// Otherwise, they are added as a delegate.
- Update {
- toggle_delegate: Actor,
- },
+ Update { toggle_delegate: Actor },
/// The repository identity is updated with a new description and given
/// [`Actor`]'s delegate status is updated in the identity document.
/// The given `parent_idx` chooses a random revision to use as the parent.
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity.rs:801:
.filter_map(|(child_id, child)| {
(child.parent == Some(id)
&& child.state == State::Active
- && self.is_majority(child.accepted().filter(|did| self.is_delegate(did)).count()))
+ && self
+ .is_majority(child.accepted().filter(|did| self.is_delegate(did)).count()))
.then_some(*child_id)
})
.collect::<Vec<_>>();
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity.rs:2537:
let eve = &network.eve;
// Create Dave as a 4th participant.
- let mut dave_node =
- Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave");
+ let mut dave_node = Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave");
dave_node.clone(network.rid, alice);
let dave_repo = NodeRepo {
repo: dave_node.storage.repository(network.rid).unwrap(),
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity.rs:2571:
// A2: Alice proposes removing Dave.
// Under A1's rules (4 delegates), majority = 3. Alice has 1 vote. Active.
let mut doc_a2 = alice_identity.doc().clone().edit();
- doc_a2.rescind(&dave_node.signer.public_key().into()).unwrap();
+ doc_a2
+ .rescind(&dave_node.signer.public_key().into())
+ .unwrap();
let a2 = alice_identity
.update(
cob::Title::new("Remove Dave").unwrap(),
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity.rs:2649:
let eve = &network.eve;
// Create Dave as a 4th participant.
- let mut dave_node =
- Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave");
+ let mut dave_node = Node::new(tempdir().unwrap(), MockSigner::from_seed([!3; 32]), "dave");
dave_node.clone(network.rid, alice);
let dave_repo = NodeRepo {
repo: dave_node.storage.repository(network.rid).unwrap(),
Diff in /e4b9b571-597f-41f7-b8e9-6ca773157859/w/crates/radicle/src/cob/identity.rs:2677:
// A2: Remove Dave → 3 delegates {Alice, Bob, Eve}, majority = 2.
let mut doc_a2 = alice_identity.doc().clone().edit();
- doc_a2.rescind(&dave_node.signer.public_key().into()).unwrap();
+ doc_a2
+ .rescind(&dave_node.signer.public_key().into())
+ .unwrap();
let a2 = alice_identity
.update(
cob::Title::new("Remove Dave").unwrap(),
Exit code: 1
{
"response": "finished",
"result": "failure"
}