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

@ -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;
}