This commit is contained in:
2026-09-22 20:34:01 +07:00
parent 706cc72c1f
commit 08c1687d87
6 changed files with 433 additions and 36 deletions
+19 -6
View File
@@ -446,7 +446,15 @@ impl Community {
*seen = (*seen).max(count); *seen = (*seen).max(count);
} }
fn missing_authority(&self) -> bool {
self.state.refounders.is_empty() && self.state.roots().len() > 1
}
pub(crate) fn tick(&mut self, cx: &mut Context<Self>) { pub(crate) fn tick(&mut self, cx: &mut Context<Self>) {
if self.missing_authority() {
self.rekey(cx);
}
let Some(channel) = self.active_channel() else { let Some(channel) = self.active_channel() else {
return; return;
}; };
@@ -776,10 +784,6 @@ impl Community {
self.state.removed_at = None; self.state.removed_at = None;
self.state.stranded = false; self.state.stranded = false;
// Who minted these epochs is only ever stated by the rotation itself,
// and a Guestbook snapshot is honored on that authority.
self.state.refounders.extend(base.refounders);
// Every channel's plane moved with the root. // Every channel's plane moved with the root.
touched.extend(self.state.channels.iter().map(|channel| channel.id)); touched.extend(self.state.channels.iter().map(|channel| channel.id));
} }
@@ -814,6 +818,13 @@ impl Community {
self.state.cursors.remove(&channel); self.state.cursors.remove(&channel);
} }
// A rotation that delivered us no key still names the npub that minted the epoch.
let learned = adoptions
.refounders
.into_iter()
.filter(|refounder| self.state.refounders.insert(*refounder))
.count();
if let Some(epoch) = adoptions.removed_at { if let Some(epoch) = adoptions.removed_at {
self.state.removed_at = Some(epoch); self.state.removed_at = Some(epoch);
} }
@@ -822,8 +833,6 @@ impl Community {
self.state.stranded = true; self.state.stranded = true;
} }
// A rotation re-opens the region its planes now cover: an exhausted
// verdict earned under the old keys cannot be trusted under the new.
for channel in &touched { for channel in &touched {
if let Some(cursor) = self.state.cursors.get_mut(channel) { if let Some(cursor) = self.state.cursors.get_mut(channel) {
cursor.exhausted = false; cursor.exhausted = false;
@@ -834,6 +843,10 @@ impl Community {
cx.notify(); cx.notify();
cx.emit(CommunityEvent::Updated(self.state.id)); cx.emit(CommunityEvent::Updated(self.state.id));
if learned > 0 {
self.refresh(cx);
}
let Some(channel) = self let Some(channel) = self
.active .active
.or_else(|| self.state.channels.first().map(|channel| channel.id)) .or_else(|| self.state.channels.first().map(|channel| channel.id))
+90 -22
View File
@@ -140,6 +140,8 @@ pub struct Adoptions {
/// The base epoch a complete rotation excluded us at. /// The base epoch a complete rotation excluded us at.
pub removed_at: Option<Epoch>, pub removed_at: Option<Epoch>,
pub stranded: bool, pub stranded: bool,
/// The npubs whose rotations minted an epoch of this community.
pub refounders: BTreeSet<PublicKey>,
} }
impl Adoptions { impl Adoptions {
@@ -149,6 +151,7 @@ impl Adoptions {
&& self.cuts.is_empty() && self.cuts.is_empty()
&& self.removed_at.is_none() && self.removed_at.is_none()
&& !self.stranded && !self.stranded
&& self.refounders.is_empty()
} }
} }
@@ -160,8 +163,6 @@ pub struct BaseAdoption {
/// The new Control Plane signing root, delivered to staff only. /// The new Control Plane signing root, delivered to staff only.
pub control_root: Option<[u8; 32]>, pub control_root: Option<[u8; 32]>,
pub stepped: Vec<HeldKey>, pub stepped: Vec<HeldKey>,
/// The npubs whose rotations minted the epochs this walk passed through.
pub refounders: BTreeSet<PublicKey>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -247,11 +248,7 @@ pub async fn adopt(
) )
.await?; .await?;
let mut refounders: BTreeSet<PublicKey> = base let mut refounders: BTreeSet<PublicKey> = base.refounders.clone();
.adopted
.as_ref()
.map(|adopted| adopted.refounders.clone())
.unwrap_or_default();
for root in state.roots().into_iter().skip(1) { for root in state.roots().into_iter().skip(1) {
let prior = walk( let prior = walk(
@@ -268,12 +265,12 @@ pub async fn adopt(
) )
.await?; .await?;
refounders.extend(prior.refounders.iter().copied());
let Some(adopted) = prior.adopted else { let Some(adopted) = prior.adopted else {
continue; continue;
}; };
refounders.extend(adopted.refounders.iter().copied());
if base if base
.adopted .adopted
.as_ref() .as_ref()
@@ -290,10 +287,10 @@ pub async fn adopt(
control_pk: step.control_pk, control_pk: step.control_pk,
control_root: step.control_root, control_root: step.control_root,
stepped: step.stepped, stepped: step.stepped,
refounders,
}); });
} }
adoptions.refounders = refounders;
adoptions.removed_at = base.removed_at; adoptions.removed_at = base.removed_at;
adoptions.stranded = base.stranded; adoptions.stranded = base.stranded;
@@ -589,6 +586,9 @@ struct Step {
adopted: Option<Adopted>, adopted: Option<Adopted>,
removed_at: Option<Epoch>, removed_at: Option<Epoch>,
stranded: bool, stranded: bool,
/// The rotators of every base rotation this walk verified, which is who may
/// seed the Guestbook snapshot of an epoch they minted.
refounders: BTreeSet<PublicKey>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -598,9 +598,6 @@ struct Adopted {
control_pk: Option<PublicKey>, control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>, control_root: Option<[u8; 32]>,
stepped: Vec<HeldKey>, stepped: Vec<HeldKey>,
/// The rotator of every base epoch this walk passed through,
/// which is who may seed the Guestbook snapshot of the epoch it minted.
refounders: BTreeSet<PublicKey>,
} }
/// What one rotation offered this client, and when it was published. /// What one rotation offered this client, and when it was published.
@@ -610,7 +607,6 @@ struct Delivery {
control_pk: Option<PublicKey>, control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>, control_root: Option<[u8; 32]>,
at_ms: u64, at_ms: u64,
rotator: PublicKey,
} }
/// Walk a scope's rotations forward, one epoch at a time, off the key held. /// Walk a scope's rotations forward, one epoch at a time, off the key held.
@@ -629,7 +625,6 @@ async fn walk(
) -> Result<Step> { ) -> Result<Step> {
let mut step = Step::default(); let mut step = Step::default();
let mut stepped: Vec<HeldKey> = Vec::new(); let mut stepped: Vec<HeldKey> = Vec::new();
let mut refounders: BTreeSet<PublicKey> = BTreeSet::new();
let ceiling = held_epoch.0 + REKEY_LOOKAHEAD; let ceiling = held_epoch.0 + REKEY_LOOKAHEAD;
loop { loop {
@@ -663,6 +658,16 @@ async fn walk(
.iter() .iter()
.filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends) .filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends)
{ {
// Who minted an epoch is proven by the rotation itself, not by a blob:
// a rotation is only a candidate after continuity against a key we
// hold, so its rotator minted this epoch whether or not it addressed
// us. A member who joined on a stale bundle never held the epochs
// between, and the snapshot that seeds them is only honored on this
// npub's authority (CORD-02 §5).
if scope == RekeyScope::Base {
step.refounders.insert(rotation.rotator);
}
let at_ms = published let at_ms = published
.get(&rotation_key(rotation)) .get(&rotation_key(rotation))
.copied() .copied()
@@ -709,7 +714,6 @@ async fn walk(
control_pk, control_pk,
control_root: delivered.control_root, control_root: delivered.control_root,
at_ms, at_ms,
rotator: rotation.rotator,
}); });
} else if let Some(held) = delivery.as_mut() { } else if let Some(held) = delivery.as_mut() {
held.at_ms = held.at_ms.min(at_ms); held.at_ms = held.at_ms.min(at_ms);
@@ -730,17 +734,12 @@ async fn walk(
held_epoch = target; held_epoch = target;
held_key = delivered.key; held_key = delivered.key;
if scope == RekeyScope::Base {
refounders.insert(delivered.rotator);
}
step.adopted = Some(Adopted { step.adopted = Some(Adopted {
epoch: target, epoch: target,
key: delivered.key, key: delivered.key,
control_pk: delivered.control_pk, control_pk: delivered.control_pk,
control_root: delivered.control_root, control_root: delivered.control_root,
stepped: stepped.clone(), stepped: stepped.clone(),
refounders: refounders.clone(),
}); });
continue; continue;
@@ -1022,7 +1021,76 @@ mod tests {
assert_eq!(base.key, NEWER_ROOT); assert_eq!(base.key, NEWER_ROOT);
assert_eq!(base.stepped.len(), 1, "the root it stepped off"); assert_eq!(base.stepped.len(), 1, "the root it stepped off");
assert_eq!(base.stepped[0].epoch, Epoch(1)); assert_eq!(base.stepped[0].epoch, Epoch(1));
assert!(base.refounders.contains(&owner.public_key())); assert!(adoptions.refounders.contains(&owner.public_key()));
});
}
/// A rotation that addresses somebody else still names the npub who minted the
/// epoch: continuity against a key we hold is what proves it. That authority is
/// what a Guestbook snapshot is honored on, so a member who joined on a stale
/// bundle can still read the roster the Refounding seeded.
#[test]
fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() {
smol::block_on(async {
let client = client();
let owner = Keys::generate();
let me = Keys::generate();
let other = Keys::generate();
let id = CommunityId::from_bytes([0x42; 32]);
let channel = ChannelId::from_bytes([0x9c; 32]);
// At epoch 2, holding the root the rotation stepped off but never
// having walked it: the shape a stale join bundle produces.
let mut state = state(owner.public_key(), id, channel);
state.root_epoch = Epoch(2);
state.community_root = NEWER_ROOT;
state.held_roots = vec![HeldRoot {
epoch: Epoch(1),
key: NEW_ROOT,
control_pk: None,
retired_at: None,
}];
let blob = smol::block_on(build_blob(
&owner,
&other.public_key(),
RekeyScope::Base,
Epoch(2),
&NEWER_ROOT,
None,
None,
))
.expect("builds");
let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives");
let wraps = smol::block_on(build_rekey_chunks(
&owner,
&group,
RekeyScope::Base,
Epoch(2),
Epoch(1),
&epoch_key_commitment(Epoch(1), &NEW_ROOT),
&[blob],
None,
false,
AT_MS / 1000,
))
.expect("builds");
store(&client, &wraps).await;
let signer = UniversalSigner::new(me.clone());
let adoptions = adopt(
&client,
&state,
&CommunityRoles::default(),
&signer,
me.public_key(),
)
.await
.expect("reads");
assert!(adoptions.base.is_none(), "nothing to adopt");
assert!(!adoptions.is_empty(), "the minter is still learned");
assert!(adoptions.refounders.contains(&owner.public_key()));
}); });
} }
+67
View File
@@ -388,6 +388,8 @@ pub async fn load(
}; };
retain_join_root(&mut state, &entry.seed); retain_join_root(&mut state, &entry.seed);
adopt_list_material(&mut state, &entry.seed);
adopt_list_material(&mut state, &entry.current);
cache::save_state(client, &state).await?; cache::save_state(client, &state).await?;
held.insert(entry.community_id, state); held.insert(entry.community_id, state);
@@ -396,6 +398,27 @@ pub async fn load(
Ok(held.into_values().collect()) Ok(held.into_values().collect())
} }
/// Take the snapshot authority and retained roots a List entry names.
fn adopt_list_material(state: &mut CommunityState, material: &JoinMaterial) {
if material.root_epoch.0 > 0
&& let Some(refounder) = material.refounder()
{
state.refounders.insert(refounder);
}
for root in material.held_roots() {
if root.epoch >= state.root_epoch || root.epoch.0 == 0 {
continue;
}
if let Some(refounder) = root.refounder {
state.refounders.insert(refounder);
}
retain_root(state, root.epoch, root.key, root.control_pk);
}
}
fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) { fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) {
if seed.root_epoch >= state.root_epoch { if seed.root_epoch >= state.root_epoch {
return; return;
@@ -967,6 +990,50 @@ mod tests {
}); });
} }
/// A List entry that names the npub whose Refounding minted its epoch hands
/// this client the authority its Guestbook snapshot is honored on, which is
/// how a device that never held the rotation still reads the seeded roster.
/// (What the fold then does with that authority is the neighboring test.)
#[test]
fn a_list_entrys_refounder_becomes_the_snapshot_authority() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let refounder = Keys::generate();
let joined = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let mut rotated = joined.clone();
rotated.root_epoch = Epoch(2);
rotated.community_root = [0x44; 32];
let mut entry = list_entry(&rotated, "coop");
entry.seed = list_entry(&joined, "coop").seed;
entry.added_at = joined.added_at_ms;
entry.current.extra.insert(
"refounder".to_owned(),
serde_json::Value::String(refounder.public_key().to_hex()),
);
let list = CommunityList::default().joined(entry);
store_fragment(&client, &signer, &list).await;
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let state = loaded
.iter()
.find(|state| state.id == joined.id)
.expect("loaded");
assert_eq!(state.root_epoch, Epoch(2));
assert!(state.refounders.contains(&refounder.public_key()));
});
}
/// A List that has rotated on keeps the root of our join: the material is /// A List that has rotated on keeps the root of our join: the material is
/// the community as we were given it, and the planes that root addressed /// the community as we were given it, and the planes that root addressed
/// stay readable only while it is held. /// stay readable only while it is held.
+123 -1
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite}; use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union}; use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
use crate::{ChannelId, CommunityId, Epoch, Extra}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_COMMUNITY_LIST: u16 = 33302; pub const KIND_COMMUNITY_LIST: u16 = 33302;
pub const MAX_MEMBERSHIPS: usize = 50; pub const MAX_MEMBERSHIPS: usize = 50;
@@ -171,6 +171,70 @@ impl CommunityList {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetainedRoot {
pub epoch: Epoch,
pub key: [u8; 32],
pub control_pk: Option<PublicKey>,
/// The npub whose Refounding minted this epoch.
pub refounder: Option<PublicKey>,
/// The epoch-seconds the superseding rotation published.
pub retired_at: Option<u64>,
}
impl JoinMaterial {
/// The npub whose Refounding minted `root_epoch`, when the document names one.
pub fn refounder(&self) -> Option<PublicKey> {
self.extra.get("refounder").and_then(named_public_key)
}
/// The prior root epochs the entry retains, with what the document knows of each.
pub fn held_roots(&self) -> Vec<RetainedRoot> {
let Some(roots) = self
.extra
.get("held_roots")
.and_then(|roots| roots.as_array())
else {
return Vec::new();
};
let mut retained = Vec::with_capacity(roots.len());
for root in roots {
let Some(fields) = root.as_object() else {
continue;
};
let Some(epoch) = fields.get("epoch").and_then(|epoch| epoch.as_u64()) else {
continue;
};
let Some(key) = fields
.get("key")
.and_then(|key| key.as_str())
.and_then(|key| decode_hex_32(&key.to_ascii_lowercase()).ok())
else {
continue;
};
retained.push(RetainedRoot {
epoch: Epoch(epoch),
key,
control_pk: fields.get("control_pk").and_then(named_public_key),
refounder: fields.get("refounder").and_then(named_public_key),
retired_at: fields.get("retired_at").and_then(|at| at.as_u64()),
});
}
retained
}
}
/// A hex npub the document names, or nothing when the field is not one.
fn named_public_key(value: &serde_json::Value) -> Option<PublicKey> {
PublicKey::from_hex(value.as_str()?).ok()
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial { pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
JoinMaterial { JoinMaterial {
community_id: invite.community_id, community_id: invite.community_id,
@@ -644,6 +708,8 @@ fn crypto_error(error: impl fmt::Display) -> ListError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use serde_json::{Value, json};
use super::*; use super::*;
fn id(byte: u8) -> CommunityId { fn id(byte: u8) -> CommunityId {
@@ -704,6 +770,62 @@ mod tests {
} }
} }
/// A peer's refounder and retained roots ride in the unknown-field map —
/// they are extensions, not §8 named fields — so the round trip has to keep
/// them, and the accessors have to read them out of it.
#[test]
fn the_wire_round_trips_a_refounder_and_the_retained_roots() {
let owner = Keys::generate().public_key();
let refounder = Keys::generate().public_key();
let mut current = material(id(0x11), owner, "Room", 3);
current
.extra
.insert("refounder".to_owned(), Value::String(refounder.to_hex()));
current.extra.insert(
"held_roots".to_owned(),
json!([
{
"epoch": 2,
"key": "77".repeat(32),
"refounder": refounder.to_hex(),
"retired_at": 1_700_000_000,
},
{ "epoch": 1, "key": "88".repeat(32) },
{ "epoch": 4 },
]),
);
let wire = serde_json::to_string(&WireSnapshot::encode(&current).expect("encodes"))
.expect("serializes");
let decoded = serde_json::from_str::<WireSnapshot>(&wire)
.expect("parses")
.decode(id(0x11))
.expect("decodes");
assert_eq!(decoded.refounder(), Some(refounder));
assert_eq!(
decoded.held_roots(),
vec![
RetainedRoot {
epoch: Epoch(2),
key: [0x77; 32],
control_pk: None,
refounder: Some(refounder),
retired_at: Some(1_700_000_000),
},
RetainedRoot {
epoch: Epoch(1),
key: [0x88; 32],
control_pk: None,
refounder: None,
retired_at: None,
},
],
"a root without a key is not a root"
);
}
#[test] #[test]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() { fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
+119 -6
View File
@@ -70,6 +70,13 @@ plus two smaller reasons the same shape comes back (§11):
re-issues its standing REQ, because a subscription that died without saying so re-issues its standing REQ, because a subscription that died without saying so
is indistinguishable from a quiet one. is indistinguishable from a quiet one.
**Revision 6.** Revision 5 gave the fold an authority to honor a Refounding
snapshot on, and retained the roots that make one reachable. One report later the
roster was still short (33 shown against Armada's 263), and the database said why:
**a rotation that delivered this client no key was verified and then thrown away**,
and the one place the reference keeps that authority — the member's own List — was
round-tripped but never read (§13).
Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b Read path today (phase 1 landed the walk, phase 2a moved each layer, phase 2b
swapped the transport and put the pump in charge of settling pages, phase 3 added swapped the transport and put the pump in charge of settling pages, phase 3 added
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so, the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
@@ -991,12 +998,14 @@ refounder.
What is honestly still missing, in the order it matters: What is honestly still missing, in the order it matters:
- **Recovery needs the old rotation chunks to still be on a relay.** A refounder - **Recovery needs the old rotation chunks to be readable at all.** A refounder
is only learnable from the rotation that named it; if the relays have dropped is only learnable from the rotation that named it; if nothing holds the chunk
the chunk for the epoch a client is on, the seeded members stay unknown until for the epoch a client is on, the seeded members stay unknown until the next
the next Refounding — the client cannot verify a snapshot it has no rotation Refounding — the client cannot verify a snapshot it has no rotation for, and
for, and accepting one from an unverified npub would let any member inject accepting one from an unverified npub would let any member inject arbitrary
arbitrary npubs into everyone's roster. npubs into everyone's roster. §13 narrows what "readable" costs (the database
alone is enough, and a rotation that never addressed us still names its
minter) and adds the second source the reference uses.
- **A Refounding this client publishes seeds nobody.** `build_snapshot_chunks` - **A Refounding this client publishes seeds nobody.** `build_snapshot_chunks`
exists and the reference publishes one at every refounding ("present members exists and the reference publishes one at every refounding ("present members
only, chunked at 400"); `Community::rotate` does not yet. It has no caller, so only, chunked at 400"); `Community::rotate` does not yet. It has no caller, so
@@ -1013,6 +1022,98 @@ What is honestly still missing, in the order it matters:
already promises, and it is asserted here by reading it against the fold, not already promises, and it is asserted here by reading it against the fold, not
by a test. by a test.
### 13. The roster was still short (revision 6) — **landed**
Revision 5 gave `fold` an authority to honor a snapshot on, and retained the roots
that make one reachable. Vector Community still showed 33 members against Armada's
263, and the wraps on disk said why.
#### 13a. What the numbers said
Folded from the wraps already in the database, one roster each:
| honored authority | members |
| ----------------- | ------- |
| `state.refounders` (empty — what the app showed) | **39** |
| the snapshot's author | **247** |
| Armada's roster | 263 |
The epoch-9 Guestbook holds 13 wraps: 11 joins, 1 leave, and one snapshot naming
**240 members**. Nothing was missing to read. The authority was.
#### 13b. A verified rotation names its minter whether or not it addressed us
§12c retained the root before the current one (7), and the rekey watch fetched the
rotation that stepped off it: **7 → 8**, complete, 3 chunks, 209 blobs, rotator
`d133ecb0…` — the same npub that authored the snapshot. `walk` verified every part
of that and then discarded the name, because the rotator was only recorded, and
only reached the state, inside the branch that *adopts* — and adoption needs a blob
addressed to us. This client joined after that Refounding, so there was no blob for
it, and the one npub the whole roster rests on was dropped by a `continue`.
**Fixed:** `walk` records the rotator of every base rotation whose continuity it
verified, adopted or not — `Step.refounders` rather than `Adopted.refounders`, so
the set survives a step that adopts nothing — and `Adoptions.refounders` now stands
on its own: `Adoptions::is_empty()` is false with authority alone, `merge_adoptions`
folds it into the state, and a newly learned refounder re-folds the roster, which is
the only thing that depends on it.
What the widening is, precisely: a rotation is a candidate only when it is
complete, continuity-valid against a key this client holds, and signed by an npub
that is authorized for the scope and not banned. A blob proves *our* inclusion, not
who minted the epoch — and the epoch is what a snapshot needs. The residual is the
one CORD-02 §5 documents and §12b already accepted: the wire never says which
epoch's stream a snapshot rode, so a verified refounder of one held epoch is
honored on another's snapshot, all of them npubs that legitimately held that key.
#### 13c. The authority a peer's List carries
Armada does not depend on the rotation chunks surviving at all: it writes the
refounder into the member's own Community List (`refounder` for the current epoch,
`held_roots[*].refounder` for retained ones, both hex extensions beside the root
keys that document already carries) and reads them back on a device that never held
a rotation. The List is sealed to self and authored by self, which is the same trust
class as the keys next to it — a different thing entirely from trusting the author
of a snapshot.
`concord` already round-tripped those fields as unknown ones. **Now read:**
`JoinMaterial::refounder()` and `JoinMaterial::held_roots()` decode them, and
`sync::load` feeds both the authority set and the retained roots from the entry's
`current` and `seed` material. Genesis names no authority, which is the reference's
own `epoch > 0` skip — no rotation minted epoch 0.
#### 13d. Recovery does not need a relay to still hold the chunk
A rekey wrap the local database already holds is a rotation this client can read
without asking anybody. `Community::tick` — the scheduler, once per
`MIN_ROUND_INTERVAL` — asks the database again whenever history spans a rotation but
no authority is known yet (`refounders` empty with more than one root held), and
stops as soon as one is learned. That is the answer to "the relays dropped the
chunk": the roster heals on the next tick rather than at the next Refounding.
Measured on that database after the fix: the same 3 wraps, no new fetch, roster
**39 → 247**.
#### 13e. Where the numbers still differ
- **We do not write those fields ourselves.** Our List material is built from the
state (`list_entry`), which carries no per-epoch attribution — `refounders` is a
set, and an honest write needs a map — so a write we make omits
`refounder`/`held_roots`, and whether a peer's copy survives one is then a
canonical-bytes tie-break rather than a guarantee. Nothing regresses today: our
own devices re-derive the authority from the rotation chunks §13b reads. Closing
it is a state-schema change (epoch → minter) plus the write, and is left as its
own step.
- **247 against Armada's 263 is `observed`.** We merge authors seen publishing in
the Guestbook and the channel planes; the reference merges anyone seen publishing
anywhere, control-plane editions included. A control wrap proves only that its
author held the plane's read key, so that stays out for now. The failure mode is a
member the other client lists and we do not — never the reverse.
- **An authority is still only as good as the rotations on disk.** A device that
installed after every relevant chunk was dropped, whose List carries no refounder,
reads an inferred roster until the next Refounding — §12c's honest miss, now with
two more ways out of it.
## Order of work ## Order of work
### Phase 2a — move the code (no behaviour change) — **landed** ### Phase 2a — move the code (no behaviour change) — **landed**
@@ -1240,6 +1341,18 @@ outstanding are the ones that need a GPUI harness or two live accounts.
and `a_list_that_moved_the_root_on_retains_the_root_of_our_join`): material at a and `a_list_that_moved_the_root_on_retains_the_root_of_our_join`): material at a
newer epoch replaces the current root and retires the one it moved past, and a newer epoch replaces the current root and retires the one it moved past, and a
List whose seed names our join keeps that root held. List whose seed names our join keeps that root held.
- A rotation that addressed somebody else — **landed in revision 6**
(`rekey::tests::a_rotation_that_delivered_us_no_key_still_names_its_refounder`):
a complete rotation off a root we hold, whose only blob is for a third party,
adopts nothing and still yields its rotator as the epoch's refounder — which is
the authority a snapshot is honored on.
- The authority a List carries — **landed in revision 6**
(`sync::tests::a_list_entrys_refounder_becomes_the_snapshot_authority`): an
entry whose material names a refounder puts that npub in the state's authority
set on load. Its wire half is
`cord02::list::tests::the_wire_round_trips_a_refounder_and_the_retained_roots`:
both extensions survive the round trip, the accessors read them, and a retained
root without a key is skipped rather than adopted.
- The page a rotated community's history sits on — the same retention is what - The page a rotated community's history sits on — the same retention is what
`a_walk_pages_back_across_a_rekey` pages across, and revision 5 is what makes a `a_walk_pages_back_across_a_rekey` pages across, and revision 5 is what makes a
*List*-learned refounding leave those keys held, not only a blob-learned one. *List*-learned refounding leave those keys held, not only a blob-learned one.
+15 -1
View File
@@ -312,13 +312,19 @@ as an inline row only when its author passes
## Membership ## Membership
```rust ```rust
let states = cord02::guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| { let states = cord02::guestbook::coalesce(&rumors, now_ms, &refounders, |actor, target, citation| {
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors) citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK) && control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
}); });
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new()); let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
``` ```
- `refounders` is the set of npubs whose rotations minted an epoch this client
verified (`CommunityState.refounders`) — a snapshot chunk is honored only from
one of them, and an empty set honors none. A rotation that delivered this
client no key still names its minter, so the set is filled by any base rotation
whose continuity verifies against a root held (`rekey::walk`); a List entry's
`refounder`, read by `list::JoinMaterial::refounder`, is the other source.
- `observed` is npub → ms for every author this client has seen publish anything - `observed` is npub → ms for every author this client has seen publish anything
usable, which is what makes a member visible before their Join arrives. Only usable, which is what makes a member visible before their Join arrives. Only
count it forward. count it forward.
@@ -553,6 +559,14 @@ fragment at every index below it. `merge` resolves a `frags` disagreement to the
larger value. (`13302`, the single-event List, is retired by the spec — a larger value. (`13302`, the single-event List, is retired by the spec — a
replaceable kind cannot fragment.) replaceable kind cannot fragment.)
A join material may also carry two hex extensions this crate does not write but
does read, because they are the only place a device that never held a rotation can
learn who minted an epoch: `refounder` names the npub whose Refounding minted the
entry's `root_epoch`, and `held_roots` is the retained prior epochs
(`[{epoch, key, refounder?, control_pk?, retired_at?}]`). `JoinMaterial::refounder()`
and `JoinMaterial::held_roots()` decode both; unknown fields are round-tripped
regardless, so a document that carries them keeps them.
The payload's 32-byte values are **unpadded base64url at every depth**, which is The payload's 32-byte values are **unpadded base64url at every depth**, which is
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
every serialization, so its output is always the canonical 43-character spelling; every serialization, so its output is always the canonical 43-character spelling;