update
This commit is contained in:
@@ -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<Self>) {
|
||||
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))
|
||||
|
||||
@@ -140,6 +140,8 @@ pub struct Adoptions {
|
||||
/// The base epoch a complete rotation excluded us at.
|
||||
pub removed_at: Option<Epoch>,
|
||||
pub stranded: bool,
|
||||
/// The npubs whose rotations minted an epoch of this community.
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
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<HeldKey>,
|
||||
/// The npubs whose rotations minted the epochs this walk passed through.
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -247,11 +248,7 @@ pub async fn adopt(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut refounders: BTreeSet<PublicKey> = base
|
||||
.adopted
|
||||
.as_ref()
|
||||
.map(|adopted| adopted.refounders.clone())
|
||||
.unwrap_or_default();
|
||||
let mut refounders: BTreeSet<PublicKey> = 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<Adopted>,
|
||||
removed_at: Option<Epoch>,
|
||||
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)]
|
||||
@@ -598,9 +598,6 @@ struct Adopted {
|
||||
control_pk: Option<PublicKey>,
|
||||
control_root: Option<[u8; 32]>,
|
||||
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.
|
||||
@@ -610,7 +607,6 @@ struct Delivery {
|
||||
control_pk: Option<PublicKey>,
|
||||
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<Step> {
|
||||
let mut step = Step::default();
|
||||
let mut stepped: Vec<HeldKey> = Vec::new();
|
||||
let mut refounders: BTreeSet<PublicKey> = 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()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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 {
|
||||
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::<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]
|
||||
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
|
||||
let owner = Keys::generate().public_key();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user