This commit is contained in:
2026-09-22 16:38:08 +07:00
parent 6fac58d494
commit 1c5ef58049
11 changed files with 808 additions and 181 deletions
Generated
-2
View File
@@ -1300,7 +1300,6 @@ dependencies = [
"common",
"community",
"gpui-pre",
"log",
"nostr-sdk",
"person",
"settings",
@@ -1338,7 +1337,6 @@ dependencies = [
"data-encoding",
"hkdf",
"hmac 0.12.1",
"log",
"nostr",
"nostr-sdk",
"rand 0.10.2",
+89 -13
View File
@@ -5,8 +5,10 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use concord::cord02::{ControlFold, ImageRef};
use concord::cord03::{self, ChatMessage, ReplyRef};
use concord::cord04::AuthorityCitation;
use concord::cord04::roles::{Permissions, citation_ok};
use concord::derive::channel_group_key;
use concord::cord06::RekeyScope;
use concord::derive::{channel_group_key, grant_locator};
use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot};
use concord::{ChannelId, CommunityId, Epoch};
use gpui::{App, AppContext, Context, EventEmitter, Task};
@@ -255,7 +257,7 @@ impl Community {
/// The epoch a channel rotation removed us at, when it removed us.
pub fn channel_removed_at(&self, channel: &ChannelId) -> Option<Epoch> {
self.state.channel_cuts.get(channel).map(|cut| Epoch(*cut))
self.state.channel_cuts.get(channel).copied()
}
/// Whether an automatic catch-up for `channel` is worth asking for yet.
@@ -653,6 +655,78 @@ impl Community {
}));
}
/// Rotate one scope to its next epoch, cutting off `excluded`.
pub fn rotate(
&mut self,
scope: RekeyScope,
recipients: &[PublicKey],
excluded: &[PublicKey],
cx: &mut Context<Self>,
) -> Option<Task<Result<Epoch>>> {
let nostr = NostrRegistry::global(cx);
let me = nostr.read(cx).current_user()?;
let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer();
if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me)
{
return None;
}
let rewrite = rekey::Rewrite {
scope,
recipients: recipients.to_vec(),
excluded: excluded.to_vec(),
citation: self.citation(&me),
};
if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) {
return None;
}
let state = self.state.clone();
let roles = self.control.roles.clone();
Some(cx.spawn(async move |this, cx| {
let epoch = rekey::rotate(
&client,
&state,
&roles,
&signer,
me,
&rewrite,
Timestamp::now(),
)
.await?;
let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?;
this.update(cx, |this, cx| {
if !adoptions.is_empty() {
this.merge_adoptions(adoptions, cx);
}
})?;
Ok(epoch)
}))
}
/// The rank this client cites when it acts.
fn citation(&self, me: &PublicKey) -> Option<AuthorityCitation> {
let entity = grant_locator(&self.state.id, &me.to_bytes());
self.state
.heads
.iter()
.find(|head| head.entity == entity)
.map(|head| AuthorityCitation {
entity: head.entity,
version: head.version,
hash: head.self_hash,
})
}
fn apply_rekey(&mut self, result: Result<Adoptions>, cx: &mut Context<Self>) {
self.rekey_task = None;
@@ -691,6 +765,8 @@ impl Community {
self.state.control_pks.insert(base.epoch.0, control_pk);
}
// The rotation is authoritative about the new epoch's signing root
self.state.control_root = base.control_root;
self.state.community_root = base.key;
self.state.root_epoch = base.epoch;
self.state.removed_at = None;
@@ -726,7 +802,7 @@ impl Community {
for (channel, epoch) in adoptions.cuts {
self.state.channels.retain(|held| held.id != channel);
self.state.channel_cuts.insert(channel, epoch.0);
self.state.channel_cuts.insert(channel, epoch);
self.state.cursors.remove(&channel);
}
@@ -934,7 +1010,7 @@ async fn sync_round(
Intent::Older { .. } => WrapPage::default(),
};
let bridge = match (intent, newest.oldest_ms, saved.newest_ms) {
let bridge = match (intent, newest.oldest, saved.newest) {
(Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => {
let page = history::page(
client,
@@ -958,8 +1034,8 @@ async fn sync_round(
};
let resume = match intent {
Intent::CatchUp => saved.oldest_ms.or(newest.oldest_ms),
Intent::Older { .. } => saved.oldest_ms,
Intent::CatchUp => saved.oldest.or(newest.oldest),
Intent::Older { .. } => saved.oldest,
};
let budget = match intent {
@@ -994,16 +1070,16 @@ async fn sync_round(
if intent == Intent::CatchUp {
let complete = !newest.failed && bridge.exhausted;
let top = newest
.newest_ms
.unwrap_or(0)
.max(bridge.newest_ms.unwrap_or(0));
.newest
.unwrap_or_default()
.max(bridge.newest.unwrap_or_default());
if complete && top > 0 {
round.newest_ms = Some(top);
if complete && !top.is_zero() {
round.newest = Some(top);
}
}
round.oldest_ms = older.oldest_ms.or(newest.oldest_ms);
round.oldest = older.oldest.or(newest.oldest);
round.exhausted = older.exhausted;
Ok((progress, round))
@@ -1082,7 +1158,7 @@ mod tests {
// ...and a channel cut closes the one channel.
let mut cut = state(channel);
cut.channel_cuts.insert(channel, 0);
cut.channel_cuts.insert(channel, Epoch(0));
assert_eq!(community(cut).channel_secret(&channel), None);
}
}
+58 -56
View File
@@ -19,13 +19,13 @@ use crate::sync::connect_relays;
/// How long one relay is given to answer one page of history.
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// How far below a cursor a warm window reaches back.
pub const CURSOR_OVERLAP_MS: u64 = 60_000;
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
/// The region of history to read.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Window {
pub until_ms: Option<u64>,
pub since_ms: Option<u64>,
pub until: Option<Timestamp>,
pub since: Option<Timestamp>,
}
impl Window {
@@ -34,28 +34,28 @@ impl Window {
Self::default()
}
/// The wraps strictly older than `oldest_ms`.
pub fn older_than(oldest_ms: u64) -> Self {
/// The wraps strictly older than `oldest`.
pub fn older_than(oldest: Timestamp) -> Self {
Self {
until_ms: Some(oldest_ms.saturating_sub(1)),
since_ms: None,
until: Some(oldest - 1u64),
since: None,
}
}
/// The region between `since_ms` and `oldest_ms`, both inclusive.
pub fn between(since_ms: u64, oldest_ms: u64) -> Self {
/// The region between `since` and `oldest`, both inclusive.
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
Self {
until_ms: Some(oldest_ms.saturating_sub(1)),
since_ms: Some(since_ms),
until: Some(oldest - 1u64),
since: Some(since),
}
}
/// The window a channel is opened with.
pub fn opening(cursor: ChannelCursor) -> Self {
match cursor.newest_ms {
Some(newest_ms) => Self {
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
match cursor.newest {
Some(newest) => Self {
since: Some(newest - CURSOR_OVERLAP),
until: None,
},
None => Self::default(),
}
@@ -139,8 +139,8 @@ pub struct WrapPage {
pub raw: usize,
/// Wraps that reached us under a held plane but that no held key could open.
pub unreadable: usize,
pub newest_ms: Option<u64>,
pub oldest_ms: Option<u64>,
pub newest: Option<Timestamp>,
pub oldest: Option<Timestamp>,
pub exhausted: bool,
pub failed: bool,
pub errors: usize,
@@ -235,7 +235,7 @@ fn read_under(
) -> Result<(OpenedStream, ChatRumor)> {
if held
.retired_at
.is_some_and(|retired| wrap.created_at.as_secs() > retired)
.is_some_and(|retired| wrap.created_at > retired)
{
bail!("sealed after the key that reads it was retired");
}
@@ -250,12 +250,12 @@ fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
.authors(authors.iter().copied())
.limit(limit);
if let Some(until_ms) = window.until_ms {
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
if let Some(until) = window.until {
filter = filter.until(until);
}
if let Some(since_ms) = window.since_ms {
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
if let Some(since) = window.since {
filter = filter.since(since);
}
filter
@@ -373,12 +373,12 @@ where
#[derive(Debug)]
struct Walk {
relays: Vec<Walker>,
since_ms: Option<u64>,
since: Option<Timestamp>,
/// The inclusive upper bound of the next page.
cursor: Option<u64>,
cursor: Option<Timestamp>,
seen: BTreeSet<EventId>,
newest_ms: Option<u64>,
oldest_ms: Option<u64>,
newest: Option<Timestamp>,
oldest: Option<Timestamp>,
raw: usize,
errors: usize,
/// Wraps the caller could not read under any held key.
@@ -402,11 +402,11 @@ impl Walk {
.cloned()
.map(|url| Walker { url, dead: false })
.collect(),
since_ms: window.since_ms,
cursor: window.until_ms,
since: window.since,
cursor: window.until,
seen: BTreeSet::new(),
newest_ms: None,
oldest_ms: None,
newest: None,
oldest: None,
raw: 0,
errors: 0,
unreadable: 0,
@@ -421,8 +421,8 @@ impl Walk {
/// The region the next page asks for.
fn region(&self) -> Window {
Window {
until_ms: self.cursor,
since_ms: self.since_ms,
until: self.cursor,
since: self.since,
}
}
@@ -444,13 +444,13 @@ impl Walk {
self.bottom = true;
}
let mut oldest: Option<u64> = None;
let mut oldest: Option<Timestamp> = None;
let mut events = Vec::with_capacity(page.len());
for event in page {
let at_ms = event.created_at.as_secs().saturating_mul(1000);
self.newest_ms = Some(self.newest_ms.map_or(at_ms, |newest| newest.max(at_ms)));
oldest = Some(oldest.map_or(at_ms, |oldest| oldest.min(at_ms)));
let at = event.created_at;
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
if self.seen.insert(event.id) {
self.raw += 1;
@@ -459,7 +459,7 @@ impl Walk {
}
match oldest {
Some(oldest) if oldest > 0 => self.cursor = Some(oldest - 1),
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
Some(_) => self.bottom = true,
None => {}
}
@@ -474,8 +474,8 @@ impl Walk {
opened,
raw: self.raw,
unreadable: self.unreadable,
newest_ms: self.newest_ms,
oldest_ms: self.oldest_ms,
newest: self.newest,
oldest: self.oldest,
exhausted: swept && self.raw > 0,
failed: self.errors > 0 || (self.bottom && self.raw == 0),
errors: self.errors,
@@ -500,9 +500,8 @@ mod tests {
let mut events: Vec<Event> = database
.iter()
.filter(|event| {
let at_ms = event.created_at.as_secs().saturating_mul(1000);
window.until_ms.is_none_or(|until| at_ms <= until)
&& window.since_ms.is_none_or(|since| at_ms >= since)
window.until.is_none_or(|until| event.created_at <= until)
&& window.since.is_none_or(|since| event.created_at >= since)
})
.cloned()
.collect();
@@ -613,7 +612,7 @@ mod tests {
let held = HeldKey {
epoch: Epoch(0),
key: SECRET,
retired_at: Some(1_000),
retired_at: Some(Timestamp::from_secs(1_000)),
};
let wrap_at = |channel: &ChannelId, at_ms: u64| {
@@ -656,7 +655,7 @@ mod tests {
assert!(page.failed);
assert!(!page.exhausted);
assert_eq!(page.raw, 0);
assert_eq!(page.oldest_ms, None);
assert_eq!(page.oldest, None);
}
#[test]
@@ -681,7 +680,10 @@ mod tests {
#[test]
fn a_page_boundary_is_exclusive() {
let database = BTreeSet::from([event_at(1_700_000_000_000), event_at(1_700_000_001_000)]);
let database = BTreeSet::from([
event_at(Timestamp::from_secs(1_700_000_000)),
event_at(Timestamp::from_secs(1_700_000_001)),
]);
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
@@ -693,16 +695,16 @@ mod tests {
let oldest = second
.iter()
.map(|event| event.created_at.as_secs() * 1000)
.map(|event| event.created_at)
.min()
.expect("one wrap");
assert_eq!(oldest, 1_700_000_000_000);
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
}
fn event_at(at_ms: u64) -> Event {
fn event_at(at: Timestamp) -> Event {
let keys = Keys::generate();
EventBuilder::new(Kind::TextNote, "page")
.custom_created_at(Timestamp::from_secs(at_ms / 1000))
.custom_created_at(at)
.finalize(&keys)
.expect("signs")
}
@@ -714,23 +716,23 @@ mod tests {
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
let warm = Window::opening(ChannelCursor {
newest_ms: Some(2_000_000),
oldest_ms: Some(1_000),
newest: Some(Timestamp::from_secs(2_000_000)),
oldest: Some(Timestamp::from_secs(1_000)),
exhausted: false,
});
assert_eq!(
warm,
Window {
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
until_ms: None,
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
until: None,
}
);
assert_eq!(
Window::older_than(1_000),
Window::older_than(Timestamp::from_secs(1_000)),
Window {
since_ms: None,
until_ms: Some(999),
since: None,
until: Some(Timestamp::from_secs(999)),
}
);
}
+297 -17
View File
@@ -1,15 +1,21 @@
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex, MutexGuard};
use anyhow::Result;
use anyhow::{Result, bail};
use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm};
use concord::cord04::roles::{CommunityRoles, Permissions};
use concord::cord06::{self, Continuity, RekeyScope, Rotation, RotationKey};
use concord::derive::{GroupKey, channel_rekey_group_key};
use concord::cord04::{self, AuthorityCitation};
use concord::cord06::{
self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan,
};
use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment};
use concord::state::{CommunityState, HeldKey};
use concord::{ChannelId, CommunityId, Epoch, cord01};
use concord::{ChannelId, CommunityId, Epoch};
use nostr_sdk::prelude::*;
use state::UniversalSigner;
use crate::sync::{self, PlaneKind};
/// Epochs ahead of a held epoch a rotation is looked for.
pub const REKEY_LOOKAHEAD: u64 = 8;
/// How much of what a relay stores a rekey watch replays.
@@ -24,6 +30,15 @@ pub struct Watch {
pub epoch: Epoch,
}
/// The permission a rotation of `scope` is judged under, by whoever reads it and
/// by this client when it publishes one.
fn permissions(scope: RekeyScope) -> &'static [u64] {
match scope {
RekeyScope::Base => &[Permissions::BAN],
RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN],
}
}
/// Every address a community's rotations can arrive at.
pub fn watches(state: &CommunityState) -> Result<Vec<Watch>> {
let mut watches = Vec::new();
@@ -135,6 +150,8 @@ pub struct BaseAdoption {
pub epoch: Epoch,
pub key: [u8; 32],
pub control_pk: Option<PublicKey>,
/// The new Control Plane signing root, delivered to staff only.
pub control_root: Option<[u8; 32]>,
pub stepped: Vec<HeldKey>,
}
@@ -209,7 +226,7 @@ pub async fn adopt(
let base = walk(
RekeyScope::Base,
&[Permissions::BAN],
permissions(RekeyScope::Base),
state.root_epoch,
state.community_root,
state,
@@ -226,9 +243,11 @@ pub async fn adopt(
epoch: step.epoch,
key: step.key,
control_pk: step.control_pk,
control_root: step.control_root,
stepped: step.stepped,
});
}
adoptions.removed_at = base.removed_at;
adoptions.stranded = base.stranded;
@@ -243,7 +262,7 @@ pub async fn adopt(
let step = walk(
RekeyScope::Channel(channel.id),
&[Permissions::MANAGE_CHANNELS, Permissions::BAN],
permissions(RekeyScope::Channel(channel.id)),
held_epoch,
held_key,
state,
@@ -272,6 +291,242 @@ pub async fn adopt(
Ok(adoptions)
}
/// A rotation this client is asked to perform.
#[derive(Debug, Clone)]
pub struct Rewrite {
pub scope: RekeyScope,
/// Every member the new key must reach.
pub recipients: Vec<PublicKey>,
/// The members it must not reach: whoever the rotation cuts off.
pub excluded: Vec<PublicKey>,
/// The rotator's claim to rank, when it holds the grant to cite.
pub citation: Option<AuthorityCitation>,
}
impl Rewrite {
/// Whether `rotator` may cut `excluded` off at all.
pub fn authorized(
&self,
roles: &CommunityRoles,
owner: &PublicKey,
rotator: &PublicKey,
) -> bool {
permissions(self.scope)
.iter()
.any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded))
}
}
/// Publish this client's own rotation of one scope to its next epoch.
#[allow(clippy::too_many_arguments)]
pub async fn rotate(
client: &Client,
state: &CommunityState,
roles: &CommunityRoles,
signer: &UniversalSigner,
me: PublicKey,
rewrite: &Rewrite,
at: Timestamp,
) -> Result<Epoch> {
if rewrite.excluded.contains(&me) {
bail!("a rotation cannot cut off the member who publishes it");
}
if !rewrite.recipients.contains(&me) {
bail!("a rotation must deliver its own rotator");
}
if rewrite
.excluded
.iter()
.any(|target| rewrite.recipients.contains(target))
{
bail!("a rotation cannot both deliver to and cut off the same member");
}
let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?;
let epoch = Epoch(held_epoch.0 + 1);
if epoch.0 > cord06::MAX_REKEY_EPOCH {
bail!("epoch {} is past the rekey ceiling", epoch.0);
}
let plan = cord06::plan_rotation(rewrite.scope, epoch)?;
let new_key = plan.new_key();
let control_pk = match &plan {
RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()),
RotationPlan::Channel { .. } => None,
};
let mut blobs = Vec::with_capacity(rewrite.recipients.len());
for recipient in &rewrite.recipients {
// A refounded Control Plane's root reaches staff only.
let control_root = match &plan {
RotationPlan::Base(refounding) => roles
.is_staff(recipient, &state.owner)
.then_some(refounding.new_control_root),
RotationPlan::Channel { .. } => None,
};
blobs.push(
cord06::build_blob(
signer,
recipient,
rewrite.scope,
epoch,
&new_key,
control_pk.as_ref(),
control_root.as_ref(),
)
.await?,
);
}
let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?;
// A rotation that cuts somebody off is the severed kind.
let severed = !rewrite.excluded.is_empty();
let mut wraps = cord06::build_rekey_chunks(
signer,
&group,
rewrite.scope,
epoch,
held_epoch,
&epoch_key_commitment(held_epoch, &held_key),
&blobs,
rewrite.citation.as_ref(),
severed,
at.as_secs(),
)
.await?;
if let RotationPlan::Base(refounding) = &plan {
let carried = carry_heads(client, state, refounding, at).await?;
if carried.is_empty() && !state.heads.is_empty() {
log::warn!("community: a refounding carried no settled head forward");
}
wraps.extend(carried);
}
sync::publish_wraps(client, &wraps, &state.relays).await;
log::debug!(
"community: rotated epoch {} to {} for {} member(s)",
held_epoch.0,
epoch.0,
blobs.len()
);
Ok(epoch)
}
/// The epoch and key a rotation steps off.
fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> {
match scope {
RekeyScope::Base => Ok((state.root_epoch, state.community_root)),
RekeyScope::Channel(channel) => state
.channels
.iter()
.find(|held| held.id == channel)
.and_then(|held| held.current())
.ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())),
}
}
/// Re-seal the settled control heads under a refounding's new groups.
///
/// A rotation only mints keys. Unless the heads ride across with it, the new
/// epoch's Control Plane starts empty, and a member whose material never carried
/// the root we are stepping off folds no roles, metadata or banlist at all.
async fn carry_heads(
client: &Client,
state: &CommunityState,
refounding: &Refounding,
at: Timestamp,
) -> Result<Vec<Event>> {
if state.heads.is_empty() {
return Ok(Vec::new());
}
let planes = sync::planes(state)?;
let authors: BTreeSet<PublicKey> = planes
.iter()
.filter(|plane| matches!(plane.kind, PlaneKind::Control(_)))
.map(|plane| plane.address)
.collect();
if authors.is_empty() {
return Ok(Vec::new());
}
let wraps = client
.database()
.query(
Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors),
)
.await?;
let mut seals = Vec::new();
let mut seen: BTreeSet<EventId> = BTreeSet::new();
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| {
matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey
}) else {
continue;
};
// A retired root reads only what was sealed before its rotation.
if !plane.accepts(wrap) {
continue;
}
let Ok(opened) =
cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true)
else {
continue;
};
// Only a plaintext seal can be carried: `compact` re-signs nothing and
// re-wraps the edition the author already sealed.
if opened.seal_form != SealForm::Plaintext {
continue;
}
let Ok(edition) = cord04::parse_edition(&opened.rumor) else {
continue;
};
let settled = state.heads.iter().any(|head| {
head.entity == edition.entity
&& head.version == edition.version
&& head.self_hash == edition.self_hash
});
// The same edition can be reachable twice once a head has been carried
// forward before: a head is published once per refounding.
if settled && seen.insert(opened.seal.id) {
seals.push(opened.seal);
}
}
if seals.is_empty() {
return Ok(Vec::new());
}
Ok(cord06::compact(
&seals,
&refounding.read(&state.id)?,
&refounding.signer(&state.id)?,
at.as_secs(),
)?)
}
/// The key grouping a rotation's chunks, recomputed from a collected rotation.
fn rotation_key(rotation: &Rotation) -> RotationKey {
(
@@ -295,9 +550,19 @@ struct Adopted {
epoch: Epoch,
key: [u8; 32],
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
stepped: Vec<HeldKey>,
}
/// What one rotation offered this client, and when it was published.
#[derive(Debug, Clone, Copy)]
struct Delivery {
key: [u8; 32],
control_pk: Option<PublicKey>,
control_root: Option<[u8; 32]>,
at_ms: u64,
}
/// Walk a scope's rotations forward, one epoch at a time, off the key held.
#[allow(clippy::too_many_arguments)]
async fn walk(
@@ -340,7 +605,7 @@ async fn walk(
break;
}
let mut delivery: Option<([u8; 32], Option<PublicKey>, u64)> = None;
let mut delivery: Option<Delivery> = None;
let mut addressed = false;
for rotation in candidates
@@ -384,34 +649,43 @@ async fn walk(
let control_pk = delivered
.control_pk
.and_then(|bytes| PublicKey::from_slice(&bytes).ok());
let raced = delivery.as_ref().map(|held| held.0);
let raced = delivery.map(|held| held.key);
// Racing rotations converge on the lowest new key.
if raced.is_none_or(|raced| delivered.new_key < raced) {
delivery = Some((delivered.new_key, control_pk, at_ms));
delivery = Some(Delivery {
key: delivered.new_key,
control_pk,
control_root: delivered.control_root,
at_ms,
});
} else if let Some(held) = delivery.as_mut() {
held.2 = held.2.min(at_ms);
held.at_ms = held.at_ms.min(at_ms);
}
}
}
if let Some((key, control_pk, at_ms)) = delivery {
if let Some(delivered) = delivery {
stepped.insert(
0,
HeldKey {
epoch: held_epoch,
key: held_key,
retired_at: Some(at_ms / 1000),
// The cutoff is a second-granular read boundary, so the
// rotation's millisecond publish time narrows to the second
// it landed in.
retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)),
},
);
held_epoch = target;
held_key = key;
held_key = delivered.key;
step.adopted = Some(Adopted {
epoch: target,
key,
control_pk,
key: delivered.key,
control_pk: delivered.control_pk,
control_root: delivered.control_root,
stepped: stepped.clone(),
});
@@ -618,7 +892,10 @@ mod tests {
assert_eq!(base.stepped.len(), 1);
assert_eq!(base.stepped[0].epoch, Epoch(0));
assert_eq!(base.stepped[0].key, ROOT);
assert_eq!(base.stepped[0].retired_at, Some(AT_MS / 1000));
assert_eq!(
base.stepped[0].retired_at,
Some(Timestamp::from_secs(AT_MS / 1000))
);
assert!(adoptions.removed_at.is_none());
assert!(!adoptions.stranded);
});
@@ -722,7 +999,10 @@ mod tests {
assert_eq!(adopted.stepped.len(), 1);
assert_eq!(adopted.stepped[0].epoch, Epoch(0));
assert_eq!(adopted.stepped[0].key, CHANNEL_KEY);
assert_eq!(adopted.stepped[0].retired_at, Some(AT_MS / 1000));
assert_eq!(
adopted.stepped[0].retired_at,
Some(Timestamp::from_secs(AT_MS / 1000))
);
assert!(adoptions.cuts.is_empty());
});
}
+20 -19
View File
@@ -17,7 +17,7 @@ use nostr_sdk::prelude::*;
use state::UniversalSigner;
use crate::cache::{self, Observed};
use crate::history::{CURSOR_OVERLAP_MS, Window};
use crate::history::{CURSOR_OVERLAP, Window};
/// How much of what a relay stores a cold subscription replays per relay.
const LIVE_REPLAY: usize = 500;
@@ -35,15 +35,15 @@ pub struct Plane {
/// The wrap's author: the control signer for Control, the group's own key otherwise.
pub address: PublicKey,
pub group: GroupKey,
/// Epoch seconds the key behind this plane was retired.
pub retired_at: Option<u64>,
/// When the rotation that retired this plane's key published.
pub retired_at: Option<Timestamp>,
}
impl Plane {
/// Whether a wrap sealed under this plane is still inside its key's life.
pub fn accepts(&self, wrap: &Event) -> bool {
self.retired_at
.is_none_or(|retired| wrap.created_at.as_secs() <= retired)
.is_none_or(|retired| wrap.created_at <= retired)
}
}
@@ -65,6 +65,7 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
control_pk: None,
retired_at: None,
});
let group = control_group_key(&root.key, &state.id, epoch)?;
planes.push(Plane {
kind: PlaneKind::Control(epoch),
@@ -124,12 +125,12 @@ pub fn plane_filter(planes: &[Plane]) -> Filter {
pub fn live_filter(planes: &[Plane], window: Window) -> Filter {
let mut filter = plane_filter(planes);
if let Some(until_ms) = window.until_ms {
filter = filter.until(Timestamp::from_secs(until_ms / 1000));
if let Some(until) = window.until {
filter = filter.until(until);
}
if let Some(since_ms) = window.since_ms {
filter = filter.since(Timestamp::from_secs(since_ms / 1000));
if let Some(since) = window.since {
filter = filter.since(since);
}
filter.limit(LIVE_REPLAY)
@@ -140,13 +141,13 @@ pub fn live_window(state: &CommunityState) -> Window {
let floor = state
.channels
.iter()
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
.min();
match floor {
Some(floor) => Window {
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
since: Some(floor - CURSOR_OVERLAP),
until: None,
},
None => Window::default(),
}
@@ -213,7 +214,7 @@ where
}
/// Best-effort publication of the genesis wraps to the community's relays.
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
connect_relays(client, relays).await;
for wrap in wraps {
@@ -428,7 +429,7 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
Some(held) => {
held.name = channel.name;
if cut.is_some_and(|cut| channel.epoch.0 <= cut) {
if cut.is_some_and(|cut| channel.epoch <= cut) {
continue;
}
@@ -767,16 +768,16 @@ mod tests {
state.cursors.insert(
channel,
concord::state::ChannelCursor {
newest_ms: Some(2_000_000),
oldest_ms: Some(1_000),
newest: Some(Timestamp::from_secs(2_000_000)),
oldest: Some(Timestamp::from_secs(1_000)),
exhausted: false,
},
);
assert_eq!(
live_window(&state),
Window {
since_ms: Some(2_000_000 - CURSOR_OVERLAP_MS),
until_ms: None,
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
until: None,
}
);
}
@@ -1138,7 +1139,7 @@ mod tests {
priors: vec![HeldKey {
epoch: Epoch(0),
key: [0x07; 32],
retired_at: Some(1_000),
retired_at: Some(Timestamp::from_secs(1_000)),
}],
}];
@@ -1148,7 +1149,7 @@ mod tests {
.find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0)))
.expect("the retired epoch keeps a plane");
assert_eq!(retired.retired_at, Some(1_000));
assert_eq!(retired.retired_at, Some(Timestamp::from_secs(1_000)));
assert!(retired.accepts(&event_at(1_000_000)));
assert!(!retired.accepts(&event_at(1_001_000)));
}
-1
View File
@@ -17,4 +17,3 @@ gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
log.workspace = true
-1
View File
@@ -18,7 +18,6 @@ rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
log.workspace = true
[dev-dependencies]
smol.workspace = true
+37
View File
@@ -534,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
})
}
/// The material one rotation delivers, by scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RotationPlan {
/// A refounding: a fresh root, with the root that reads and signs under it.
Base(Refounding),
/// A channel: a fresh channel key, and nothing beside it.
Channel { epoch: Epoch, new_key: [u8; 32] },
}
impl RotationPlan {
pub fn epoch(&self) -> Epoch {
match self {
Self::Base(refounding) => refounding.epoch,
Self::Channel { epoch, .. } => *epoch,
}
}
/// The secret the rotation delivers, which becomes the scope's new read key.
pub fn new_key(&self) -> [u8; 32] {
match self {
Self::Base(refounding) => refounding.new_root,
Self::Channel { new_key, .. } => *new_key,
}
}
}
/// Mint what a rotation of `scope` delivers at `epoch`.
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
match scope {
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
epoch,
new_key: random_32()?,
}),
}
}
/// Carries the settled heads across a refounding.
pub fn compact(
seals: &[Event],
+97 -22
View File
@@ -22,8 +22,10 @@ pub const STATE_PREFIX: &str = "concord/";
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
/// The publish time of the rotation that superseded this key: a wrap
/// sealed under it later than this does not read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
pub retired_at: Option<Timestamp>,
}
/// A community root epoch the client still holds, retained for the same reason.
@@ -34,8 +36,9 @@ pub struct HeldRoot {
/// The epoch's Control Plane signer, when the rotation delivered one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// The publish time of the rotation that superseded this root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<u64>,
pub retired_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -59,15 +62,15 @@ impl ChannelKeyRef {
}
}
/// How far a channel's history sync has reached, in epoch milliseconds.
/// How far a channel's history sync has reached, in wrap times.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelCursor {
/// The newest wrap ingested, so a live subscription knows where to resume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub newest_ms: Option<u64>,
pub newest: Option<Timestamp>,
/// The oldest wrap paged back to, so the next round resumes below it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest_ms: Option<u64>,
pub oldest: Option<Timestamp>,
/// History verifiably swept to the bottom.
#[serde(default)]
pub exhausted: bool,
@@ -76,14 +79,14 @@ pub struct ChannelCursor {
impl ChannelCursor {
pub fn merge(self, round: Self) -> Self {
Self {
newest_ms: later(self.newest_ms, round.newest_ms),
oldest_ms: earlier(self.oldest_ms, round.oldest_ms),
newest: later(self.newest, round.newest),
oldest: earlier(self.oldest, round.oldest),
exhausted: self.exhausted || round.exhausted,
}
}
}
fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.max(round)),
(held, None) => held,
@@ -91,7 +94,7 @@ fn later(held: Option<u64>, round: Option<u64>) -> Option<u64> {
}
}
fn earlier(held: Option<u64>, round: Option<u64>) -> Option<u64> {
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.min(round)),
(held, None) => held,
@@ -121,23 +124,27 @@ pub struct CommunityState {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
/// Where each channel's history sync has reached.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
#[serde(
default,
rename = "channel_cursors",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs superseded by a rotation we adopted, newest first.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, u64>,
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// A complete rotation ahead of our epoch predates our join and carries no
/// blob for us: a stale invite landed us on a superseded epoch.
/// A complete rotation ahead of our epoch predates our join and carries no blob.
#[serde(default)]
pub stranded: bool,
#[serde(default)]
pub dissolved: bool,
/// When this community joined the member's list, in milliseconds.
pub added_at_ms: u64,
}
@@ -326,7 +333,7 @@ impl CommunityState {
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
self.channel_cuts
.get(channel)
.is_some_and(|cut| epoch.0 <= *cut)
.is_some_and(|cut| epoch <= *cut)
}
pub fn floors(&self) -> Floors {
@@ -431,8 +438,8 @@ mod tests {
#[test]
fn a_cursor_merge_only_moves_forward_and_never_seals() {
let held = ChannelCursor {
newest_ms: Some(1_000),
oldest_ms: Some(5_000),
newest: Some(Timestamp::from_secs(1_000)),
oldest: Some(Timestamp::from_secs(5_000)),
exhausted: false,
};
@@ -440,15 +447,15 @@ mod tests {
assert_eq!(held.merge(ChannelCursor::default()), held);
let merged = held.merge(ChannelCursor {
newest_ms: Some(2_000),
oldest_ms: Some(3_000),
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
});
assert_eq!(
merged,
ChannelCursor {
newest_ms: Some(2_000),
oldest_ms: Some(3_000),
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
}
);
@@ -456,14 +463,82 @@ mod tests {
// A later round that learned less cannot walk either bound back.
assert_eq!(
merged.merge(ChannelCursor {
newest_ms: Some(1_500),
oldest_ms: Some(4_000),
newest: Some(Timestamp::from_secs(1_500)),
oldest: Some(Timestamp::from_secs(4_000)),
exhausted: false,
}),
merged
);
}
/// A cursor stored when the boundaries were milliseconds must not be read as
/// seconds. The key it was stored under is gone, so the document's counters
/// are ignored and the channel re-syncs rather than being sealed off by an
/// `exhausted` that outlived the bounds it was earned against.
#[test]
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
let channel = ChannelId::from_bytes([0x9c; 32]);
let cursor = ChannelCursor {
newest: Some(Timestamp::from_secs(1_700_000_000)),
exhausted: true,
..ChannelCursor::default()
};
let mut state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: None,
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 7,
};
// The document a version that stored milliseconds wrote: its own key,
// and boundaries padded by a thousand.
let mut stored = serde_json::Map::new();
stored.insert(
channel.to_hex(),
serde_json::json!({
"newest_ms": 1_700_000_000_000u64,
"oldest_ms": 1_699_999_000_000u64,
"exhausted": true
}),
);
let mut legacy = serde_json::to_value(&state).expect("serializes");
legacy
.as_object_mut()
.expect("a document")
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
assert!(
read.cursors.is_empty(),
"a millisecond cursor is not a seconds cursor"
);
// A typed cursor still round-trips under the key it is written with.
state.cursors.insert(channel, cursor);
let document = serde_json::to_value(&state).expect("serializes");
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
assert_eq!(read.cursors.get(&channel), Some(&cursor));
}
#[test]
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let owner = Keys::generate().public_key();
+162 -24
View File
@@ -35,9 +35,20 @@ subscriptions too, which is what a page REQ is (`nostr-sdk/src/relay/inner.rs`
`RelayNotification::Authenticated` arm). Only `AuthenticationFailed` ends a relay's
wait, and it is reported rather than rendered as an empty channel.
**Revision 3.** One more correction, from a review of what revision 2 landed:
4. **Time is a type, not a `u64` with the unit in its name.** The read path held
three different units in the same `u64`: `HeldKey`/`HeldRoot.retired_at` held
seconds (but were compared with a `Timestamp` through `.as_secs()`),
`ChannelCursor` held milliseconds that were only ever seconds multiplied by a
thousand, and `CommunityState.channel_cuts` held epochs. Every filter boundary
divided by a thousand on the way out. §10 types the time fields (`Timestamp`,
`Epoch`) and leaves a millisecond only where a millisecond is real.
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):
the rekey watch and held epochs, phase 4 made an empty or unreadable room say so,
phase 5 typed the times they all compare):
```
CommunityPanel::load community_ui/src/lib.rs:192
@@ -277,10 +288,10 @@ impl Window {
/// Nothing held: ask wide, the round pages down from there.
/// Something held: ask only for what is new, plus an overlap for the seam.
pub fn opening(cursor: ChannelCursor) -> Self {
match cursor.newest_ms {
Some(newest_ms) => Window {
since_ms: Some(newest_ms.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
match cursor.newest {
Some(newest) => Window {
since: Some(newest - CURSOR_OVERLAP),
until: None,
},
None => Window::default(),
}
@@ -296,13 +307,13 @@ pub fn live_window(state: &CommunityState) -> Window {
let floor = state
.channels
.iter()
.filter_map(|channel| state.cursors.get(&channel.id)?.newest_ms)
.filter_map(|channel| state.cursors.get(&channel.id)?.newest)
.min();
match floor {
Some(floor) => Window {
since_ms: Some(floor.saturating_sub(CURSOR_OVERLAP_MS)),
until_ms: None,
since: Some(floor - CURSOR_OVERLAP),
until: None,
},
None => Window::default(),
}
@@ -352,7 +363,7 @@ relay.
Rules carried over from phase 1, none of which are negotiable:
- **A page boundary is exclusive.** `until = oldest_seen_ms - 1`, so consecutive
- **A page boundary is exclusive.** `until = oldest_seen - 1`, so consecutive
pages never share an event and the walk terminates.
- **`exhausted` is earned.** Only `raw > 0` plus a bottomed-out page on every
relay that answered may set it. An all-empty page sets `failed`, so the next
@@ -366,8 +377,8 @@ Rules carried over from phase 1, none of which are negotiable:
as refused, and it is surfaced.
- **One cursor, one filter per page.** The database is not per relay, so per-relay
cursors do not exist; a relay is a source that fills the database.
- **`newest_ms` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
`oldest_ms` only walks down on a complete page, and `exhausted` is cleared by a
- **`newest` advances only on a complete round** (`!newest.failed && bridge.exhausted`),
`oldest` only walks down on a complete page, and `exhausted` is cleared by a
rekey.
The cold/warm distinction is now the *window rule* rather than a special case:
@@ -375,9 +386,9 @@ The cold/warm distinction is now the *window rule* rather than a special case:
| State of the channel | Window |
| -------------------- | ------ |
| no cursor, nothing cached (cold open) | `Window::opening``since = None`: subscribe for everything the relays have, then page down |
| cursor with `newest_ms` (warm open) | `Window::opening``since = newest - CURSOR_OVERLAP`: new data only |
| cursor with `oldest_ms` | `Window::older_than(oldest_ms)`: older data, on demand |
| a hole between `saved.newest_ms` and the newest seen | `Window::between(..)`: the bridge |
| cursor with `newest` (warm open) | `Window::opening``since = newest - CURSOR_OVERLAP`: new data only |
| cursor with `oldest` | `Window::older_than(oldest)`: older data, on demand |
| a hole between `saved.newest` and the newest seen | `Window::between(..)`: the bridge |
So an open does two things, and they follow the same rule: it makes sure the live
REQ is installed — wide when nothing is held, which is where "subscribe to get all
@@ -390,9 +401,9 @@ And the passes fall out of that rule:
- **Newest pass** (`CatchUp`): one page at `Window::opening(saved)`. Cold, this is
"get all data"; warm, this is "get only new data".
- **Bridge**: while the previous page was *full* and still above
`saved.newest_ms`, keep walking down with `until = oldest - 1`, bounded by
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest_ms` advance.
- **Older pass**: resume at `saved.oldest_ms.or(oldest_seen)` and walk down,
`saved.newest`, keep walking down with `until = oldest - 1`, bounded by
`CATCH_UP_PAGES`. A short page ends it, which is what lets `newest` advance.
- **Older pass**: resume at `saved.oldest.or(oldest_seen)` and walk down,
bounded by `CATCH_UP_PAGES` on a catch-up and `LOAD_OLDER_PAGES` on a scroll-up.
Sizes stay the reference numbers: `PAGE_WRAPS = 50`, `CATCH_UP_PAGES = 20`,
@@ -452,7 +463,7 @@ the next ones:
- `ChannelKeyRef` gains
`#[serde(default, skip_serializing_if = "Vec::is_empty")] priors: Vec<HeldKey>`
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<u64> }`, and
where `HeldKey { epoch: Epoch, key: [u8; 32], retired_at: Option<Timestamp> }`, and
`CommunityState` gains the same shape for roots (`HeldRoot { epoch, key,
`control_pk, retired_at }`). `channel_secret` returns every held epoch,
`history::page` already takes `&[(Epoch, [u8; 32])]`, and `sync::refresh` stops
@@ -521,9 +532,49 @@ Three deviations from the sketch above, all of them smaller than planned:
adoptable, so a member who missed several rotations catches up in one database
read instead of one pass per poll. The lookahead window is what feeds it.
Still deferred, and named here so it is not mistaken for landed: a rotation this
client published itself is not adopted locally (no rekey writer exists yet, so the
watch only ever adopts another member's).
#### The writer (phase 6) — **landed**
Phase 3 gave the client a receiver; phase 6 gives it a hand. `rekey::rotate`
builds the rotation from the key the client actually holds and publishes it, and
`Community::rotate` is the entry a moderation surface will call:
- **One plan, drawn in the protocol crate.** `cord06::plan_rotation(scope, epoch)`
mints what the rotation delivers — a `Refounding` (new root and control pair)
for a base scope, a fresh key for a channel — beside `plan_refounding`, which is
now the base arm of it. No caller ever holds a rotation secret before the
rotation exists.
- **Authority first, and the same authority the receiver applies.** `Rewrite::authorized`
runs `cord06::rekey_authorized` under `permissions(scope)` — the same list
`adopt` now walks with, shared instead of written twice — so the client cannot
publish a rotation it would not itself adopt. `Community::rotate` adds the three
states the role fold cannot see: a removal, a strand and a ban.
- **One blob per recipient, and the rotator is one of them.** A rotation that
delivered no blob to its own rotator would strand them, so the writer refuses
one. Staff get the new Control Plane root beside the key, everyone else gets the
key alone.
- **A refounding carries its heads.** `carry_heads` reads the control editions
back out of the store, picks the ones the settled floors name, and `compact`s
them onto the new epoch's groups. Without it the new Control Plane starts empty
and the next reader that does not hold the old root folds no roles, metadata or
banlist at all. (`compact` re-signs nothing, so this only works on the plaintext
seals the Control Plane already uses.)
- **Then it adopts itself, through the ordinary receiver.** The chunks are saved
into the local database as they are sent, so `Community::rotate` calls the same
`rekey::adopt` the watch calls instead of adopting by construction: one path for
every rotation, whoever wrote it. Publish failures are logged per relay and do
not change the held state, so a rotation that only reached some relays still
leaves this client consistent — which is the same asymmetry the reference has.
- **The receiver now keeps the signing root it is handed.** `BaseAdoption`
carries the delivered `control_root` through to `state.control_root`; before
this, a refounding's root was dropped at the receiver and a staff member went on
signing under the epoch they had left.
What is still not here: the trigger. Kicking a member, choosing a channel's
remaining audience, and rendering any of it is the moderation and
community-management surface §8 keeps out of scope, so `Community::rotate` is an
API with no caller in the app yet — and `Rewrite.recipients` is the caller's to
name, because a private channel's audience is in each member's own invite and not
in the local state.
### 7. Honest states (phase 4) — **landed**
@@ -659,6 +710,50 @@ NIP-44-open them all. The plan's per-channel work queue in the pump turned out t
be unnecessary for that: the first fold after a live wrap already opens exactly
the uncached wraps.
### 10. Time is a type: `Timestamp`, not padded milliseconds (phase 5) — **landed**
The read path compared times through a bare `u64` in three units, and each
boundary paid for the confusion:
| Value | Held | What it cost |
| ----- | ---- | ------------ |
| `HeldKey.retired_at`, `HeldRoot.retired_at`, `Plane.retired_at` | seconds | compared with `wrap.created_at.as_secs()`, written as `at_ms / 1000` |
| `ChannelCursor.newest_ms`/`oldest_ms`, `WrapPage`, `Window`, the `Walk` bounds | milliseconds, always `secs * 1000` | `* 1000` on every accepted page, `/ 1000` on every filter |
| `CommunityState.channel_cuts` | epochs | `Epoch` unpacked to `u64` and rewrapped at the boundary |
| `added_at_ms`, a rumor's `at_ms` | milliseconds, genuinely | — |
What landed:
- `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor.{newest,oldest}`,
`WrapPage.{newest,oldest}`, `Plane.retired_at`, `Window.{until,since}` and the
`Walk` bounds are `Timestamp`s. A window bound and a wrap's `created_at` are now
the same type, so `read_under` and `Plane::accepts` compare them directly,
`wrap_filter`/`live_filter` hand them to `until`/`since` unchanged, and the
page's exclusive boundary is written `oldest - 1` instead of `oldest_ms - 1`
truncated back to seconds.
- `CURSOR_OVERLAP` is a `Duration` (`60s`), not `60_000`, so
`newest - CURSOR_OVERLAP` reads as what it is.
- `channel_cuts` is `BTreeMap<ChannelId, Epoch>`, which is what
`rekey::Adoptions::cuts` already carried.
- **The two millisecond fields stay milliseconds.** A rumor's time is genuinely
finer than a wrap's second-granular `created_at`: CORD-01 carries the sub-second
offset in an `ms` tag and `resolve_ms_strict` puts it back, so a reader's
`at_ms` and `timeline`'s `before_ms` keep it. The cord02 list document's
`added_at`/`removed_at` keep it too, and not merely for fidelity: `added_at` is
an ordering key judged against a tombstone's `removed_at` under a strict `>`,
so narrowing both to seconds could tie a rejoin with the leave that preceded it.
- **A stored cursor is retired, not reinterpreted.** A document written when the
boundaries were milliseconds would deserialize into a `Timestamp` far in the
future, and its `exhausted` would then wedge the older pass for good. The map's
serde key changed (`cursors``channel_cursors`), so such a record is dropped
and the next round rebuilds the cursor from the wraps it reads;
`a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted` pins
both halves of that. `retired_at` needed no such treatment, because it was
already written in seconds.
No behaviour changes with it: the walk paged the same regions before and after,
because every millisecond it held was a second multiplied by a thousand.
## Order of work
### Phase 2a — move the code (no behaviour change) — **landed**
@@ -777,13 +872,42 @@ Five recorded deviations:
Phase 4 leaves the client honest about what it can see and what it can write.
What it does not do is make it see more: the rekey writer, and with it adopting a
rotation this client published itself, remains open (see §6).
rotation this client published itself, was still open there (§6, closed in
phase 6).
Each phase leaves the client consistent on its own. Phase 2a was invisible; phase
2b is what makes "open a community" a subscription and a database read; phase 3
is what keeps a rekey from stranding history; phase 4 is what makes an empty or
unreadable room tell the truth.
### Phase 5 — typed time — **landed**
§10. `HeldKey`/`HeldRoot.retired_at`, `ChannelCursor`, `WrapPage`, `Plane`,
`Window` and the `Walk` bounds are `Timestamp`s; `channel_cuts` is `Epoch`;
`CURSOR_OVERLAP` is a `Duration`; and the cursor map's serde key was retired so no
stored millisecond cursor is read as seconds. No behaviour change, no relay
contact, no new dependency — the units the read path already compared by hand are
now the types it compares.
Gate, all green: `cargo test -p concord -p community` (51 + 32),
`cargo clippy -p concord -p community -p community_ui --all-targets`,
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
`cargo check -p workspace --all-targets`.
### Phase 6 — the rekey writer — **landed**
§6's writer: `cord06::plan_rotation`, `rekey::{Rewrite, rotate}`, `carry_heads`,
and `Community::rotate`, which publishes a rotation and then adopts it through the
receiver phase 3 built. The receiver also keeps the signing root a refounding
hands it now, instead of dropping it.
Gate, all green: `cargo test -p concord -p community` (51 + 32),
`cargo clippy -p concord -p community -p community_ui --all-targets`,
`cargo +nightly fmt -p concord -p community -p community_ui --check`,
`cargo check -p workspace --all-targets`. No test was added for the writer itself:
the publish half needs a relay, and the same reason the reference's own rekey
paths are exercised by hand applies here — see `Tests` below.
## Phase 1 status (landed)
What phase 1 delivered, and what phase 2 replaces:
@@ -832,6 +956,10 @@ outstanding are the ones that need a GPUI harness or two live accounts.
`Window::opening(saved)` starts at `newest - CURSOR_OVERLAP`; `Window::older_than`
never includes the boundary event; `sync::live_window` is wide cold and resumes
at the oldest held cursor warm.
- The cursor's unit — **landed in phase 5**: a merge only moves forward and never
earns `exhausted`; a document whose cursors were stored in the old millisecond
encoding reads as no cursors at all, while a typed one round-trips
(`concord/src/state.rs`).
- The subscription plan — **landed in 2b**: a private channel's plane appears when
the key is held and is absent when it is not; `plane_filter` asks for both wrap
kinds and addresses every readable plane. The page-REQ half (a warm open's REQ
@@ -845,8 +973,15 @@ outstanding are the ones that need a GPUI harness or two live accounts.
rotator who outranks us, published after we joined, reads as a removal; a
channel rotation replaces the key and keeps the prior. The pump's half
(`community/src/lib.rs`) is that a rekey watch's event wakes its community.
- The page registry**landed in 2b**: a page that has already unregistered
receives nothing.
- The rekey writer**landed in phase 6, structurally only.** No test covers it:
the half that could be tested without a relay (that `plan_rotation` mints two
unrelated keys, that `Rewrite::authorized` agrees with the receiver) would not
catch the failures that matter (a blob addressed to the wrong epoch, a chunk set
the receiver cannot collect, a refounding that carries no head), and the half
that would — publish, re-read, adopt — needs a real relay and a second account.
What stands in for it is that the writer cannot take a path the receiver does
not: same `permissions`, same `build_rekey_chunks`, and adoption goes through
`rekey::adopt` rather than beside it.
- The read path: the side-event budget folds an edit/delete/reaction older than
the row window onto its message.
- What cannot be read — **landed in phase 4**: a wrap sealed after the rotation
@@ -885,6 +1020,9 @@ outstanding are the ones that need a GPUI harness or two live accounts.
Unread badges, notifications, message threads, pins, typing indicators and
presence (21059 wraps are wired for routing here, not for those features), file
and media rendering, moderation actions, and the community-management surfaces.
The rekey writer is the one exception, and only its mechanism: `Community::rotate`
exists to be called, but nothing in the app calls it. Kicking a member, choosing
the audience a rotation keeps, and showing any of it stay out.
`crates/chat`'s DM path shares none of this code and is not touched; a later
change can lift the page walk/cursor into a shared module if DMs grow the same
paging.
+48 -26
View File
@@ -245,7 +245,7 @@ let page = history::page(
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
```
A key's `retired_at` (epoch seconds, set when a rotation supersedes it) is a read
A key's `retired_at` (a `Timestamp`, set when a rotation supersedes it) is a read
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
epoch is history and never a live plane an ejected holder can keep writing into.
@@ -271,21 +271,24 @@ the handshake completes, so the page waits for the resubscribed answer rather
than writing off a relay that only wanted to authenticate.
`history::page` walks newest-first across every held epoch, caches what it opens, and
reports what it saw: `oldest_ms`/`newest_ms` feed the caller's `ChannelCursor`,
reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`,
`exhausted` is earned only by a short page *after* history was seen, and an
all-empty answer sets `failed` so a later round re-asks instead of sealing the
channel at "no more history". `unreadable` counts the wraps the page reached that
no held key could open — sealed past the cutoff their key's rotation set, or bound
to another channel — because those are history the reader is missing, not history
that is not there. Page down with `Window::older_than(seen.oldest_ms)`,
open a channel with `Window::opening(cursor)` (wide cold, `newest_ms - 60s` warm),
and read the region between two cursors with `Window::between(..)`. `query_rumors`
is the read path when the group keys are gone; pass `kinds` to budget rows apart
from the events that only decorate them. `cache::wrapper_index` reads the cached
rows back keyed by the wrap they came from, which is what lets `sync::fold` observe
author and message times without re-opening a wrap it already cached, and
`cache::purge_expired(client, &channel, now)` runs at the top of every round — the
timer is cooperative, so the local store is the artifact that has to forget.
that is not there. Page down with `Window::older_than(seen.oldest)`,
open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm),
and read the region between two cursors with `Window::between(..)`. A `Window`
carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit
conversion; only a reader's millisecond `at_ms` narrows to a second at the query.
`query_rumors` is the read path when the group keys are gone; pass `kinds` to
budget rows apart from the events that only decorate them. `cache::wrapper_index`
reads the cached rows back keyed by the wrap they came from, which is what lets
`sync::fold` observe author and message times without re-opening a wrap it already
cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every
round — the timer is cooperative, so the local store is the artifact that has to
forget.
`sync::fold` counts the same thing over the whole store, per channel, in
`Snapshot.unreadable`. `sync_round` sums a round's pages into
@@ -415,25 +418,25 @@ whether a link still stands, and `fits()` is the write gate.
## Rekeys, refounding and dissolution
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
gates it, `plan_refounding(epoch)` mints the new pair, and `build_rekey_chunks`
seals one blob per remaining member:
gates it, `plan_rotation(scope, epoch)` mints what it delivers, and
`build_rekey_chunks` seals one blob per remaining member:
```rust
use concord::derive::epoch_key_commitment;
use concord::cord06::{self, RekeyScope};
use concord::cord06::{self, RekeyScope, RotationPlan};
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
let plan = cord06::plan_refounding(Epoch(epoch + 1))?;
let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?;
let new_key = plan.new_key();
// A base rotation delivers the new control-plane keys beside the root; a channel
// rotation delivers only that channel's fresh key.
let new_key = plan.new_root;
let (control_pk, control_root) = match scope {
RekeyScope::Base => {
let pk = plan.signer(&community_id)?.pk().to_bytes();
(Some(pk), is_staff.then_some(&plan.new_control_root))
let (control_pk, control_root) = match &plan {
RotationPlan::Base(refounding) => {
let pk = refounding.signer(&community_id)?.pk().to_bytes();
(Some(pk), is_staff.then_some(&refounding.new_control_root))
}
RekeyScope::Channel(_) => (None, None),
RotationPlan::Channel { .. } => (None, None),
};
let mut blobs = Vec::with_capacity(members.len());
@@ -441,18 +444,24 @@ let mut blobs = Vec::with_capacity(members.len());
for member in &members {
blobs.push(
cord06::build_blob(
&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
&my_keys,
member,
scope,
plan.epoch(),
&new_key,
control_pk.as_ref(),
control_root,
)
.await?,
);
}
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch())?;
let wraps = cord06::build_rekey_chunks(
&my_keys,
&rekey_group,
scope,
plan.epoch,
plan.epoch(),
Epoch(epoch),
&epoch_key_commitment(Epoch(epoch), &community_root),
&blobs,
@@ -476,6 +485,18 @@ waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
with the rotation's publish time as its read cutoff, and reports a removal or a
strand when a complete rotation carries no blob for the member.
`community::rekey::rotate` is the sender, run against the held state: it resolves
the epoch and key the scope is stepping off, refuses a rotation that would skip or
cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`,
which the caller names because a private channel's audience is not in the local
state), marks the rotation severed when it excludes somebody, and — for a base
scope — carries the settled Control Plane heads onto the new epoch's groups with
`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same
`rekey_authorized` under the same permissions `adopt` applies, publishes to the
community's relays, and then adopts what it published through `rekey::adopt`
rather than by construction: a rotation this client wrote and one it received take
one path.
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
text-only, so `build_blob` carries it base64-encoded inside the envelope.
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
@@ -737,5 +758,6 @@ client.subscribe(filter).with_id(sub_id).await?;
rendered as a notice **and** enforced at write time: `Community::send` refuses
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
never held), because a wrap sealed under a superseded root would reach nobody who
rotated. The residual gap: this client does not adopt a rotation it published
itself, because there is no rekey writer yet.
rotated. A rotation this client publishes itself (`Community::rotate`) is adopted
through the same `rekey::adopt` an arriving one goes through, so the held state
and the wire cannot diverge.