Phase 2f: groups as a distinct primitive alongside circles

Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.

Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
  When `Some`, the record represents a group rooted at that public post;
  when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
  index `idx_group_keys_root` on it. Migration added for upgraded DBs;
  the CREATE TABLE statement carries the column so fresh DBs match.

Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
  field. v0.6.1 peers will deserialize it as absent and continue treating
  the record as a circle. All three sender sites (new-circle distribute,
  add-member distribute, epoch-rotation distribute) pass the field through.

Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
  and read the new column. Added a shared `row_to_group_key` helper so the
  two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
  used by posting / retrieval flows.

Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
  backing circle named `group:<6-byte-hex-of-root>`, initializes the
  group key with `canonical_root_post_id` set, and invites each initial
  member (reusing `add_to_circle`'s wrap+distribute path so members get
  the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
  the group seed can call this. Looks up the group by root, routes
  through `create_post_with_visibility(Circle(name))` (which already
  chooses `GroupEncrypted` when the seed is present), then stores a
  `ThreadMeta` row linking the new post back to the canonical root so
  retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
  via the ThreadMeta parent index. Callers decrypt normally; members see
  full content, non-members see encrypted blobs.

Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
  `create_group_key_inner(circle_name, canonical_root)` helper, keeping
  one place where the record is constructed and the seed is persisted.

Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
  circles already used. The "admin-only post" restriction on circles was
  a UX choice, not a cryptographic limit — groups expose the many-way
  path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
  comment threads; groups reuse it so the query pattern
  ("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
  and exercise-able via Tauri/CLI.

Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
This commit is contained in:
Scott Reimers 2026-04-22 22:58:39 -04:00
parent 88d5cc9f23
commit 2cb211eb11
5 changed files with 261 additions and 29 deletions

View file

@ -5133,6 +5133,7 @@ impl ConnectionManager {
group_public_key: payload.group_public_key,
admin: payload.admin,
created_at: now_ms(),
canonical_root_post_id: payload.canonical_root_post_id,
};
let _ = storage.create_group_key(&record, None);

View file

@ -1721,6 +1721,7 @@ impl Node {
group_public_key: gk.group_public_key,
admin: self.node_id,
member_keys: vec![mk],
canonical_root_post_id: gk.canonical_root_post_id,
})
}
Err(e) => {
@ -1758,6 +1759,120 @@ impl Node {
/// Create a group key for a circle (called on circle creation).
async fn create_group_key_for_circle(&self, circle_name: &str) -> anyhow::Result<()> {
self.create_group_key_inner(circle_name, None).await
}
// ---- Groups (v0.6.2) ----
/// Create a new group anchored at `root_post_id`. Unlike circles, groups
/// are many-way: every member can post to the group once they've
/// received the wrapped group seed. Returns the `(GroupId, circle_name)`
/// pair used internally; the circle_name is synthesised from the root
/// post id so there's no user-visible naming step.
pub async fn create_group_from_post(
&self,
root_post_id: PostId,
initial_members: Vec<NodeId>,
) -> anyhow::Result<(crate::types::GroupId, String)> {
let circle_name = format!("group:{}", hex::encode(&root_post_id[..6]));
// Create the backing circle row + initialize group key with
// canonical_root_post_id set, then add each initial member (which
// wraps + distributes the key).
{
let storage = self.storage.get().await;
storage.create_circle(&circle_name)?;
}
self.create_group_key_inner(&circle_name, Some(root_post_id)).await?;
for member in initial_members {
if member == self.node_id {
continue;
}
if let Err(e) = self.add_to_circle(circle_name.clone(), member).await {
warn!(member = hex::encode(member), error = %e, "failed to add group member");
}
}
let group_id = {
let storage = self.storage.get().await;
storage.get_group_key_by_circle(&circle_name)?
.map(|gk| gk.group_id)
.ok_or_else(|| anyhow::anyhow!("group key missing after creation"))?
};
info!(
root = hex::encode(root_post_id),
group_id = hex::encode(group_id),
circle_name = %circle_name,
"Created group from post"
);
Ok((group_id, circle_name))
}
/// Post to a group anchored at `root_post_id`. Any member holding the
/// group seed can call this. Encrypts the content with the group key and
/// records a `ThreadMeta` link from the new post back to the root so
/// `list_group_posts_by_root` can later cluster all contributions.
pub async fn post_to_group(
&self,
root_post_id: PostId,
content: String,
attachment_data: Vec<(Vec<u8>, String)>,
) -> anyhow::Result<(PostId, Post, PostVisibility)> {
let circle_name = {
let storage = self.storage.get().await;
storage.get_group_by_canonical_root(&root_post_id)?
.map(|gk| gk.circle_name)
.ok_or_else(|| anyhow::anyhow!("no group found for canonical root post"))?
};
let result = self.create_post_with_visibility(
content,
VisibilityIntent::Circle(circle_name),
attachment_data,
).await?;
// Link the new post back to the canonical root so the group can be
// reconstructed by `list_group_posts_by_root`.
{
let storage = self.storage.get().await;
storage.store_thread_meta(&crate::types::ThreadMeta {
post_id: result.0,
parent_post_id: root_post_id,
})?;
}
Ok(result)
}
/// List all posts that belong to the group rooted at `root_post_id`.
/// Reads the ThreadMeta parent index + returns the full posts. Callers
/// decrypt as needed (same as any other GroupEncrypted content).
pub async fn list_group_posts_by_root(
&self,
root_post_id: PostId,
) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
let storage = self.storage.get().await;
let child_ids = storage.get_thread_children(&root_post_id)?;
let mut out = Vec::with_capacity(child_ids.len());
for pid in child_ids {
if let Some((post, vis)) = storage.get_post_with_visibility(&pid)? {
out.push((pid, post, vis));
}
}
Ok(out)
}
// ---- end Groups ----
/// Shared group-key creation used by both circles (canonical_root=None)
/// and groups (canonical_root=Some).
async fn create_group_key_inner(
&self,
circle_name: &str,
canonical_root_post_id: Option<PostId>,
) -> anyhow::Result<()> {
let (seed, pubkey) = crypto::generate_group_keypair();
let group_id = crypto::compute_group_id(&pubkey);
let now = std::time::SystemTime::now()
@ -1771,6 +1886,7 @@ impl Node {
group_public_key: pubkey,
admin: self.node_id,
created_at: now,
canonical_root_post_id,
};
let storage = self.storage.get().await;
@ -1812,6 +1928,7 @@ impl Node {
group_public_key: pubkey,
admin: self.node_id,
member_keys: vec![mk],
canonical_root_post_id,
};
self.network.push_group_key(member, &payload).await;
}
@ -1844,7 +1961,7 @@ impl Node {
}
match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) {
Ok((new_seed, new_pubkey, new_epoch, member_keys)) => {
Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string()))
Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string(), gk.canonical_root_post_id))
}
Err(e) => {
warn!(error = %e, "Failed to rotate group key");
@ -1853,7 +1970,7 @@ impl Node {
}
};
if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name)) = rotate_result {
if let Some((group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name, canonical_root)) = rotate_result {
// Update storage
{
let storage = self.storage.get().await;
@ -1876,6 +1993,7 @@ impl Node {
group_public_key: new_pubkey,
admin: self.node_id,
member_keys: vec![mk.clone()],
canonical_root_post_id: canonical_root,
};
self.network.push_group_key(&mk.member, &payload).await;
}

View file

@ -395,6 +395,11 @@ pub struct GroupKeyDistributePayload {
pub group_public_key: [u8; 32],
pub admin: NodeId,
pub member_keys: Vec<GroupMemberKey>,
/// v0.6.2: when set, this record is a group rooted at the given public
/// post. Absent on v0.6.1 nodes — deserializes to `None` and behaves
/// like a traditional circle.
#[serde(default)]
pub canonical_root_post_id: Option<PostId>,
}
/// Member requests current group key (bi-stream request)

View file

@ -283,9 +283,11 @@ impl Storage {
group_public_key BLOB NOT NULL,
group_seed BLOB,
admin BLOB NOT NULL,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
canonical_root_post_id BLOB
);
CREATE INDEX IF NOT EXISTS idx_group_keys_circle ON group_keys(circle_name);
CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);
CREATE TABLE IF NOT EXISTS group_member_keys (
group_id BLOB NOT NULL,
member BLOB NOT NULL,
@ -648,6 +650,19 @@ impl Storage {
)?;
}
// v0.6.2: add canonical_root_post_id to group_keys. When set, the
// record is a group (many-way, anchored at a public root post);
// when NULL it's a traditional circle (one-way, admin-only).
let has_canonical_root = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('group_keys') WHERE name='canonical_root_post_id'"
)?.query_row([], |row| row.get::<_, i64>(0))?;
if has_canonical_root == 0 {
self.conn.execute_batch(
"ALTER TABLE group_keys ADD COLUMN canonical_root_post_id BLOB DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_group_keys_root ON group_keys(canonical_root_post_id);"
)?;
}
// Add device_role column to peers if missing (Active CDN replication)
let has_device_role = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'"
@ -2149,7 +2164,7 @@ impl Storage {
pub fn create_group_key(&self, record: &GroupKeyRecord, group_seed: Option<&[u8; 32]>) -> anyhow::Result<()> {
self.conn.execute(
"INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
"INSERT OR REPLACE INTO group_keys (group_id, circle_name, epoch, group_public_key, group_seed, admin, created_at, canonical_root_post_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
record.group_id.as_slice(),
record.circle_name,
@ -2158,14 +2173,40 @@ impl Storage {
group_seed.map(|s| s.as_slice()),
record.admin.as_slice(),
record.created_at as i64,
record.canonical_root_post_id.as_ref().map(|r| r.as_slice()),
],
)?;
Ok(())
}
fn row_to_group_key(
gid: Vec<u8>,
circle_name: String,
epoch: i64,
gpk: Vec<u8>,
admin: Vec<u8>,
created_at: i64,
canonical_root: Option<Vec<u8>>,
) -> anyhow::Result<GroupKeyRecord> {
let canonical_root_post_id = match canonical_root {
Some(b) => Some(blob_to_postid(b)?),
None => None,
};
Ok(GroupKeyRecord {
group_id: blob_to_nodeid(gid)?,
circle_name,
epoch: epoch as u64,
group_public_key: <[u8; 32]>::try_from(gpk.as_slice())
.map_err(|_| anyhow::anyhow!("invalid group public key"))?,
admin: blob_to_nodeid(admin)?,
created_at: created_at as u64,
canonical_root_post_id,
})
}
pub fn get_group_key(&self, group_id: &GroupId) -> anyhow::Result<Option<GroupKeyRecord>> {
let result = self.conn.query_row(
"SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE group_id = ?1",
"SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE group_id = ?1",
params![group_id.as_slice()],
|row| {
let gid: Vec<u8> = row.get(0)?;
@ -2174,20 +2215,13 @@ impl Storage {
let gpk: Vec<u8> = row.get(3)?;
let admin: Vec<u8> = row.get(4)?;
let created_at: i64 = row.get(5)?;
Ok((gid, circle_name, epoch, gpk, admin, created_at))
let canonical_root: Option<Vec<u8>> = row.get(6)?;
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root))
},
);
match result {
Ok((gid, circle_name, epoch, gpk, admin, created_at)) => {
Ok(Some(GroupKeyRecord {
group_id: blob_to_nodeid(gid)?,
circle_name,
epoch: epoch as u64,
group_public_key: <[u8; 32]>::try_from(gpk.as_slice())
.map_err(|_| anyhow::anyhow!("invalid group public key"))?,
admin: blob_to_nodeid(admin)?,
created_at: created_at as u64,
}))
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => {
Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
@ -2196,7 +2230,7 @@ impl Storage {
pub fn get_group_key_by_circle(&self, circle_name: &str) -> anyhow::Result<Option<GroupKeyRecord>> {
let result = self.conn.query_row(
"SELECT group_id, circle_name, epoch, group_public_key, admin, created_at FROM group_keys WHERE circle_name = ?1",
"SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE circle_name = ?1",
params![circle_name],
|row| {
let gid: Vec<u8> = row.get(0)?;
@ -2205,20 +2239,39 @@ impl Storage {
let gpk: Vec<u8> = row.get(3)?;
let admin: Vec<u8> = row.get(4)?;
let created_at: i64 = row.get(5)?;
Ok((gid, circle_name, epoch, gpk, admin, created_at))
let canonical_root: Option<Vec<u8>> = row.get(6)?;
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root))
},
);
match result {
Ok((gid, circle_name, epoch, gpk, admin, created_at)) => {
Ok(Some(GroupKeyRecord {
group_id: blob_to_nodeid(gid)?,
circle_name,
epoch: epoch as u64,
group_public_key: <[u8; 32]>::try_from(gpk.as_slice())
.map_err(|_| anyhow::anyhow!("invalid group public key"))?,
admin: blob_to_nodeid(admin)?,
created_at: created_at as u64,
}))
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => {
Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Look up a group by its canonical root post id. Returns None if the
/// record has no canonical_root_post_id (i.e. it's a circle).
pub fn get_group_by_canonical_root(&self, root_post_id: &PostId) -> anyhow::Result<Option<GroupKeyRecord>> {
let result = self.conn.query_row(
"SELECT group_id, circle_name, epoch, group_public_key, admin, created_at, canonical_root_post_id FROM group_keys WHERE canonical_root_post_id = ?1",
params![root_post_id.as_slice()],
|row| {
let gid: Vec<u8> = row.get(0)?;
let circle_name: String = row.get(1)?;
let epoch: i64 = row.get(2)?;
let gpk: Vec<u8> = row.get(3)?;
let admin: Vec<u8> = row.get(4)?;
let created_at: i64 = row.get(5)?;
let canonical_root: Option<Vec<u8>> = row.get(6)?;
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root))
},
);
match result {
Ok((gid, circle_name, epoch, gpk, admin, created_at, canonical_root)) => {
Ok(Some(Self::row_to_group_key(gid, circle_name, epoch, gpk, admin, created_at, canonical_root)?))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
@ -5450,6 +5503,7 @@ mod tests {
group_public_key: pubkey,
admin,
created_at: 1000,
canonical_root_post_id: None,
};
s.create_group_key(&record, Some(&seed)).unwrap();
@ -5507,6 +5561,49 @@ mod tests {
assert!(s.get_group_seed(&group_id, 1).unwrap().is_none());
}
#[test]
fn group_lookup_by_canonical_root() {
let s = temp_storage();
let admin = make_node_id(1);
let group_id = [43u8; 32];
let pubkey = [100u8; 32];
let root = make_post_id(99);
let record = crate::types::GroupKeyRecord {
group_id,
circle_name: "group:test".to_string(),
epoch: 1,
group_public_key: pubkey,
admin,
created_at: 1000,
canonical_root_post_id: Some(root),
};
s.create_group_key(&record, None).unwrap();
// Lookup by root returns the group.
let got = s.get_group_by_canonical_root(&root).unwrap().unwrap();
assert_eq!(got.group_id, group_id);
assert_eq!(got.canonical_root_post_id, Some(root));
// A different root returns None.
let other = make_post_id(7);
assert!(s.get_group_by_canonical_root(&other).unwrap().is_none());
// A circle (no canonical_root) is not returned when looking up by root.
let circle_record = crate::types::GroupKeyRecord {
group_id: [44u8; 32],
circle_name: "friends".to_string(),
epoch: 1,
group_public_key: [101u8; 32],
admin,
created_at: 1000,
canonical_root_post_id: None,
};
s.create_group_key(&circle_record, None).unwrap();
// The circle has no root, so it's invisible to the root lookup.
assert!(s.get_group_by_canonical_root(&make_post_id(0)).unwrap().is_none());
}
#[test]
fn group_seeds_map() {
let s = temp_storage();
@ -5522,6 +5619,7 @@ mod tests {
group_public_key: pubkey,
admin,
created_at: 1000,
canonical_root_post_id: None,
};
s.create_group_key(&record, Some(&seed)).unwrap();
s.store_group_seed(&group_id, 1, &seed).unwrap();

View file

@ -177,7 +177,13 @@ pub struct GroupMemberKey {
pub wrapped_group_key: Vec<u8>,
}
/// A group key record (circle ↔ group key binding)
/// A group key record (circle ↔ group key binding).
///
/// v0.6.2: `canonical_root_post_id` distinguishes **groups** (many-way,
/// anchored at a public root post; any member can post) from **circles**
/// (one-way, admin-only, `None`). The encryption primitives are identical;
/// the flag is a UX + query hint so UIs can cluster group posts under
/// their root.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupKeyRecord {
pub group_id: GroupId,
@ -186,6 +192,10 @@ pub struct GroupKeyRecord {
pub group_public_key: [u8; 32],
pub admin: NodeId,
pub created_at: u64,
/// When set, this record represents a group rooted at the given public
/// post. When `None`, the record is a traditional circle.
#[serde(default)]
pub canonical_root_post_id: Option<PostId>,
}
/// Visibility of a post — separate from Post struct so it doesn't affect PostId