diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index b3e34bf7..1cff92fe 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -446,7 +446,15 @@ impl Community { *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) { + if self.missing_authority() { + self.rekey(cx); + } + let Some(channel) = self.active_channel() else { return; }; @@ -776,10 +784,6 @@ impl Community { self.state.removed_at = None; 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. touched.extend(self.state.channels.iter().map(|channel| channel.id)); } @@ -814,6 +818,13 @@ impl Community { 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 { self.state.removed_at = Some(epoch); } @@ -822,8 +833,6 @@ impl Community { 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 { if let Some(cursor) = self.state.cursors.get_mut(channel) { cursor.exhausted = false; @@ -834,6 +843,10 @@ impl Community { cx.notify(); cx.emit(CommunityEvent::Updated(self.state.id)); + if learned > 0 { + self.refresh(cx); + } + let Some(channel) = self .active .or_else(|| self.state.channels.first().map(|channel| channel.id)) diff --git a/crates/community/src/rekey.rs b/crates/community/src/rekey.rs index 94af1b46..787c3777 100644 --- a/crates/community/src/rekey.rs +++ b/crates/community/src/rekey.rs @@ -140,6 +140,8 @@ pub struct Adoptions { /// The base epoch a complete rotation excluded us at. pub removed_at: Option, pub stranded: bool, + /// The npubs whose rotations minted an epoch of this community. + pub refounders: BTreeSet, } impl Adoptions { @@ -149,6 +151,7 @@ impl Adoptions { && self.cuts.is_empty() && self.removed_at.is_none() && !self.stranded + && self.refounders.is_empty() } } @@ -160,8 +163,6 @@ pub struct BaseAdoption { /// The new Control Plane signing root, delivered to staff only. pub control_root: Option<[u8; 32]>, pub stepped: Vec, - /// The npubs whose rotations minted the epochs this walk passed through. - pub refounders: BTreeSet, } #[derive(Debug, Clone)] @@ -247,11 +248,7 @@ pub async fn adopt( ) .await?; - let mut refounders: BTreeSet = base - .adopted - .as_ref() - .map(|adopted| adopted.refounders.clone()) - .unwrap_or_default(); + let mut refounders: BTreeSet = base.refounders.clone(); for root in state.roots().into_iter().skip(1) { let prior = walk( @@ -268,12 +265,12 @@ pub async fn adopt( ) .await?; + refounders.extend(prior.refounders.iter().copied()); + let Some(adopted) = prior.adopted else { continue; }; - refounders.extend(adopted.refounders.iter().copied()); - if base .adopted .as_ref() @@ -290,10 +287,10 @@ pub async fn adopt( control_pk: step.control_pk, control_root: step.control_root, stepped: step.stepped, - refounders, }); } + adoptions.refounders = refounders; adoptions.removed_at = base.removed_at; adoptions.stranded = base.stranded; @@ -589,6 +586,9 @@ struct Step { adopted: Option, removed_at: Option, 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, } #[derive(Debug, Clone)] @@ -598,9 +598,6 @@ struct Adopted { control_pk: Option, control_root: Option<[u8; 32]>, stepped: Vec, - /// 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, } /// What one rotation offered this client, and when it was published. @@ -610,7 +607,6 @@ struct Delivery { control_pk: Option, control_root: Option<[u8; 32]>, at_ms: u64, - rotator: PublicKey, } /// Walk a scope's rotations forward, one epoch at a time, off the key held. @@ -629,7 +625,6 @@ async fn walk( ) -> Result { let mut step = Step::default(); let mut stepped: Vec = Vec::new(); - let mut refounders: BTreeSet = BTreeSet::new(); let ceiling = held_epoch.0 + REKEY_LOOKAHEAD; loop { @@ -663,6 +658,16 @@ async fn walk( .iter() .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 .get(&rotation_key(rotation)) .copied() @@ -709,7 +714,6 @@ async fn walk( control_pk, control_root: delivered.control_root, at_ms, - rotator: rotation.rotator, }); } else if let Some(held) = delivery.as_mut() { held.at_ms = held.at_ms.min(at_ms); @@ -730,17 +734,12 @@ async fn walk( held_epoch = target; held_key = delivered.key; - if scope == RekeyScope::Base { - refounders.insert(delivered.rotator); - } - step.adopted = Some(Adopted { epoch: target, key: delivered.key, control_pk: delivered.control_pk, control_root: delivered.control_root, stepped: stepped.clone(), - refounders: refounders.clone(), }); continue; @@ -1022,7 +1021,76 @@ mod tests { assert_eq!(base.key, NEWER_ROOT); assert_eq!(base.stepped.len(), 1, "the root it stepped off"); 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())); }); } diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 6af8ea50..8a3b1ab1 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -388,6 +388,8 @@ pub async fn load( }; 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?; held.insert(entry.community_id, state); @@ -396,6 +398,27 @@ pub async fn load( 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) { if seed.root_epoch >= state.root_epoch { 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 /// the community as we were given it, and the planes that root addressed /// stay readable only while it is held. diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index 05d2aa71..16066018 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord05::{ChannelGrant, CommunityInvite}; 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 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, + /// The npub whose Refounding minted this epoch. + pub refounder: Option, + /// The epoch-seconds the superseding rotation published. + pub retired_at: Option, +} + +impl JoinMaterial { + /// The npub whose Refounding minted `root_epoch`, when the document names one. + pub fn refounder(&self) -> Option { + 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 { + 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::from_hex(value.as_str()?).ok() +} + pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial { JoinMaterial { community_id: invite.community_id, @@ -644,6 +708,8 @@ fn crypto_error(error: impl fmt::Display) -> ListError { #[cfg(test)] mod tests { + use serde_json::{Value, json}; + use super::*; 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(¤t).expect("encodes")) + .expect("serializes"); + let decoded = serde_json::from_str::(&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] fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() { let owner = Keys::generate().public_key(); diff --git a/docs/community-history-sync-plan.md b/docs/community-history-sync-plan.md index f09233e9..17db1ca3 100644 --- a/docs/community-history-sync-plan.md +++ b/docs/community-history-sync-plan.md @@ -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 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 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, @@ -991,12 +998,14 @@ refounder. What is honestly still missing, in the order it matters: -- **Recovery needs the old rotation chunks to still be on a relay.** A refounder - is only learnable from the rotation that named it; if the relays have dropped - the chunk for the epoch a client is on, the seeded members stay unknown until - the next Refounding — the client cannot verify a snapshot it has no rotation - for, and accepting one from an unverified npub would let any member inject - arbitrary npubs into everyone's roster. +- **Recovery needs the old rotation chunks to be readable at all.** A refounder + is only learnable from the rotation that named it; if nothing holds the chunk + for the epoch a client is on, the seeded members stay unknown until the next + Refounding — the client cannot verify a snapshot it has no rotation for, and + accepting one from an unverified npub would let any member inject arbitrary + 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` 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 @@ -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 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 ### 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 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. +- 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 `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. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index cc852284..74c23a08 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -312,13 +312,19 @@ as an inline row only when its author passes ## Membership ```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) && control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK) }); 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 usable, which is what makes a member visible before their Join arrives. Only 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 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 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;