Skip to content

Commit ac0b2cc

Browse files
net: derive gossip topic from team root; drop --topic flag
The gossip topic name was a separate string the user picked and coordinated with invitees, alongside the team root pubkey they already had to share for auth. Two identifiers per team that were effectively one — and a footgun: invitee types `--topic our-team` instead of `our-team-graph`, joins a different gossip mesh, sees no live HEAD updates, no error. Collapse them. The team root is already 32 uniform bytes (an ed25519 pubkey, perfect as a `TopicId` directly — no hashing needed). One identifier per team handles both: - cap chain verification (auth) - gossip mesh rendezvous (live HEAD updates) Breaking changes: * triblespace-net: `PeerConfig.gossip_topic: Option<String>` → `PeerConfig.gossip: bool`. `gossip = true` derives the topic from `team_root.to_bytes()`. `gossip = false` is serve/pull- only (no subscription). Migration: `Some(_)` → `true`, `None` → `false`. * trible: `pile net sync --topic NAME` flag removed. Sync always joins the team's gossip mesh, identified by `TRIBLE_TEAM_ROOT` (or single-user fallback to the node's own pubkey when unset). Doc/example updates: triblespace-net/README, trible/README, book/src/distributed-sync, book/src/capability-auth. Tests green: `triblespace-net` unit (3) + integration (2), `trible` bin (1). Network-touching e2e tests not exercised in this commit — their PeerConfig sites don't reference the old field name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d847969 commit ac0b2cc

9 files changed

Lines changed: 78 additions & 40 deletions

File tree

book/src/capability-auth.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ The invitee then runs the relay (or any pile-net peer) with
127127
```bash
128128
$ TRIBLE_TEAM_ROOT=1a8a6a9d... \
129129
TRIBLE_TEAM_CAP=7afe59e7... \
130-
trible pile net sync /path/to/their.pile --peers <founder-id> --topic team-graph
130+
trible pile net sync /path/to/their.pile --peers <founder-id>
131131
```
132132

133133
Without those env vars the peer falls back to a single-user
@@ -234,8 +234,9 @@ use std::collections::HashSet;
234234
let pile = triblespace::core::repo::pile::Pile::open(path)?;
235235
let peer = Peer::new(pile, signing_key.clone(), PeerConfig {
236236
peers: vec![bootstrap_endpoint_id],
237-
gossip_topic: Some("my-team-graph".into()),
238-
team_root: team_root_pubkey, // 32 bytes, the team's CA
237+
gossip: true, // false = pull/serve-only
238+
team_root: team_root_pubkey, // 32 bytes — the team's CA AND
239+
// the gossip mesh id when gossip=true
239240
revoked: HashSet::new(), // boot-time seed (usually empty)
240241
self_cap: my_own_cap_sig_handle, // what we present on OP_AUTH
241242
});

book/src/distributed-sync.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ use std::collections::HashSet;
4646
let pile = triblespace::core::repo::pile::Pile::open(path)?;
4747
let peer = Peer::new(pile, signing_key.clone(), PeerConfig {
4848
peers: vec![bootstrap_endpoint_id],
49-
gossip_topic: Some("my-team-graph".into()),
49+
gossip: true, // false = pull/serve-only
5050
// Auth is mandatory — see the Capability Auth chapter for the
5151
// team-root + self_cap setup, or run `trible team create`.
52+
// The team root pubkey doubles as the gossip mesh id when
53+
// `gossip = true`.
5254
team_root: signing_key.verifying_key(), // single-user team-of-one
5355
revoked: HashSet::new(),
5456
self_cap: [0u8; 32],
@@ -201,13 +203,15 @@ The `trible` CLI exposes sync via the `pile net` subcommand:
201203
trible pile net identity [--key PATH]
202204
Print this node's iroh identity (generates a key if needed).
203205
204-
trible pile net sync <PILE> [--peers ...] [--topic T] [--key PATH]
205-
Long-running bidirectional sync. Without --topic, serves only
206-
(accepts direct pulls but doesn't gossip). With --topic, joins
207-
the gossip mesh and auto-merges incoming tracking branches into
208-
same-named local ones every tick. Reads `TRIBLE_TEAM_ROOT` and
209-
`TRIBLE_TEAM_CAP` env vars for multi-user team operation; falls
210-
back to single-user team-of-one without them.
206+
trible pile net sync <PILE> [--peers ...] [--key PATH]
207+
Long-running bidirectional sync on the team's gossip mesh.
208+
The mesh is identified by the team root pubkey directly (no
209+
separate --topic flag): every team has exactly one mesh,
210+
derived from its identity. Auto-merges incoming tracking
211+
branches into same-named local ones every tick. Reads
212+
`TRIBLE_TEAM_ROOT` and `TRIBLE_TEAM_CAP` env vars for multi-
213+
user team operation; falls back to single-user team-of-one
214+
using the node's own pubkey when those aren't set.
211215
212216
trible pile net pull <PILE> <REMOTE> --branch NAME [--key PATH]
213217
One-shot pull of a named branch from a specific peer (REMOTE is

trible/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## Unreleased
8+
9+
### Removed (breaking)
10+
- **`pile net sync --topic` flag.** The gossip mesh is now
11+
identified by `TRIBLE_TEAM_ROOT` directly — every team has
12+
exactly one gossip mesh, derived from its identity. Users no
13+
longer pick + coordinate a separate topic string with
14+
invitees. Migration: drop the `--topic` flag from any sync
15+
invocation; the mesh topic is now always the team root
16+
pubkey. Falls back to single-user team-of-one (the node's
17+
own pubkey) when `TRIBLE_TEAM_ROOT` isn't set.
18+
719
## [0.37.0] - 2026-05-06
820

921
### Added

trible/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ their own team root).
103103

104104
- `pile net identity [--key PATH]` — print this node's iroh identity (auto-generates a key if missing).
105105
- `pile net status [--key PATH]` — print the auth configuration this node would present on `OP_AUTH`: node id, team root, self_cap, and where each value comes from (env var vs fallback). For debugging stuck-auth scenarios.
106-
- `pile net sync <PILE> [--peers ID,...] [--topic NAME] [--key PATH]` — long-running bidirectional sync. Without `--topic`, serves only (accepts direct pulls but doesn't gossip). With `--topic`, joins the gossip mesh and auto-merges incoming tracking branches into same-named local ones every tick. Reads `TRIBLE_TEAM_ROOT` and `TRIBLE_TEAM_CAP` env vars for multi-user team operation.
106+
- `pile net sync <PILE> [--peers ID,...] [--key PATH]` — long-running bidirectional sync on the team's gossip mesh. The mesh is identified by the team root pubkey directly (no separate topic argument): every team has exactly one mesh, derived from its identity. Auto-merges incoming tracking branches into same-named local ones every tick. Reads `TRIBLE_TEAM_ROOT` and `TRIBLE_TEAM_CAP` env vars; falls back to the node's own pubkey for single-user / team-of-one workflows.
107107
- `pile net pull <PILE> <REMOTE> --branch NAME [--key PATH]` — one-shot pull of a named branch from a specific peer (REMOTE is the peer's iroh node id, 64-char hex). Pull-only mode — no gossip subscription, direct QUIC + DHT fetch, materializes a tracking branch and merges into local. Same env-var fallback as `sync`.
108108

109109
### Capability auth

trible/src/cli/pile/net.rs

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,15 @@ pub enum Command {
8484
#[arg(long)]
8585
key: Option<PathBuf>,
8686
},
87-
/// Sync with peers. No topic = serve only. With topic = live bidirectional sync.
87+
/// Sync with peers — live bidirectional gossip on the team's
88+
/// gossip mesh (topic = team root pubkey). The team root is read
89+
/// from `TRIBLE_TEAM_ROOT`, falling back to this node's own
90+
/// pubkey for single-user / team-of-one workflows.
8891
Sync {
8992
pile: PathBuf,
9093
#[arg(long, value_delimiter = ',')]
9194
peers: Vec<String>,
9295
#[arg(long)]
93-
topic: Option<String>,
94-
#[arg(long)]
9596
key: Option<PathBuf>,
9697
},
9798
/// One-shot pull a branch from a remote peer.
@@ -109,8 +110,8 @@ pub fn run(cmd: Command) -> Result<()> {
109110
match cmd {
110111
Command::Identity { key } => run_identity(key),
111112
Command::Status { key } => run_status(key),
112-
Command::Sync { pile, peers, topic, key } => {
113-
run_sync(pile, peers, topic, key)
113+
Command::Sync { pile, peers, key } => {
114+
run_sync(pile, peers, key)
114115
}
115116
Command::Pull { pile, remote, branch, key } => {
116117
run_pull(pile, remote, branch, key)
@@ -168,7 +169,7 @@ fn run_status(sk: Option<PathBuf>) -> Result<()> {
168169

169170
// ── Sync ─────────────────────────────────────────────────────────────
170171

171-
fn run_sync(pile_path: PathBuf, peer_strs: Vec<String>, topic: Option<String>, key_path: Option<PathBuf>) -> Result<()> {
172+
fn run_sync(pile_path: PathBuf, peer_strs: Vec<String>, key_path: Option<PathBuf>) -> Result<()> {
172173
use triblespace_core::repo::Repository;
173174

174175
let key = load_or_create_key(&key_path, key_dir(&pile_path))?;
@@ -183,7 +184,7 @@ fn run_sync(pile_path: PathBuf, peer_strs: Vec<String>, topic: Option<String>, k
183184
let self_cap = self_cap_from_env()?;
184185
let peer = Peer::new(pile, key.clone(), PeerConfig {
185186
peers,
186-
gossip_topic: topic.clone(),
187+
gossip: true,
187188
team_root,
188189
revoked: std::collections::HashSet::new(),
189190
self_cap,
@@ -192,12 +193,8 @@ fn run_sync(pile_path: PathBuf, peer_strs: Vec<String>, topic: Option<String>, k
192193
.map_err(|e| anyhow!("repo: {e:?}"))?;
193194

194195
eprintln!("node: {}", repo.storage().id());
195-
if let Some(ref t) = topic {
196-
eprintln!("topic: {t}");
197-
eprintln!("live sync active. (Ctrl-C to stop)\n");
198-
} else {
199-
eprintln!("serving. (Ctrl-C to stop)");
200-
}
196+
eprintln!("team_root: {} (gossip topic)", hex::encode(team_root.to_bytes()));
197+
eprintln!("live sync active. (Ctrl-C to stop)\n");
201198

202199
// Initial broadcast so peers connecting later can learn our state.
203200
repo.storage_mut().republish_branches();
@@ -207,7 +204,7 @@ fn run_sync(pile_path: PathBuf, peer_strs: Vec<String>, topic: Option<String>, k
207204
// Periodic re-broadcast: helps newly-joined gossip neighbors learn
208205
// about us. iroh-gossip dedupes identical messages so re-publishing
209206
// the same state is cheap.
210-
if topic.is_some() && last_announce.elapsed() > std::time::Duration::from_secs(10) {
207+
if last_announce.elapsed() > std::time::Duration::from_secs(10) {
211208
repo.storage_mut().republish_branches();
212209
last_announce = std::time::Instant::now();
213210
}
@@ -246,15 +243,15 @@ fn run_pull(pile_path: PathBuf, remote: String, branch: String, key_path: Option
246243
.map_err(|e| anyhow!("bad node ID: {e}"))?;
247244
let remote_endpoint: iroh_base::EndpointId = remote_key.into();
248245

249-
// Spin up the Peer — pull-only mode (gossip_topic: None), no flood
246+
// Spin up the Peer — pull-only mode (gossip: false), no flood
250247
// subscription, just direct fetch + DHT.
251248
use triblespace_core::repo::Repository;
252249
let pile = open_pile(&pile_path)?;
253250
let team_root = team_root_from_env(&key)?;
254251
let self_cap = self_cap_from_env()?;
255252
let peer = Peer::new(pile, key.clone(), PeerConfig {
256253
peers: Vec::new(),
257-
gossip_topic: None,
254+
gossip: false,
258255
team_root,
259256
revoked: std::collections::HashSet::new(),
260257
self_cap,

triblespace-net/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Changed (breaking)
11+
- **`PeerConfig.gossip_topic: Option<String>`
12+
`PeerConfig.gossip: bool`.** The gossip topic is now derived
13+
from `team_root` directly (an ed25519 pubkey is already 32
14+
uniform bytes — perfect as a `TopicId`, no hashing needed),
15+
so users no longer pick + coordinate a separate topic
16+
string. One identifier per team handles both auth (cap
17+
chain) and rendezvous (gossip mesh) — no way to join the
18+
right team on the wrong gossip channel and silently see no
19+
HEAD updates.
20+
Migration: `gossip_topic: Some(_)``gossip: true`,
21+
`gossip_topic: None``gossip: false`.
22+
823
## [0.37.0] - 2026-05-06
924

1025
The auth-arc tests-and-polish release. No protocol changes —

triblespace-net/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ triblespace = { version = "0.35", features = ["net"] }
2020
use triblespace::net::peer::{Peer, PeerConfig};
2121
2222
let pile = triblespace::core::repo::pile::Pile::open(path)?;
23-
let peer = Peer::new(pile, signing_key, PeerConfig {
23+
let peer = Peer::new(pile, signing_key.clone(), PeerConfig {
2424
peers: vec![bootstrap_endpoint_id],
25-
gossip_topic: Some("my-team-graph".into()),
25+
gossip: true,
26+
team_root: signing_key.verifying_key(), // single-user fallback
27+
revoked: HashSet::new(),
28+
self_cap: [0u8; 32],
2629
});
2730
// From here it's just a triblespace store — commit, push, pull, query.
2831
```

triblespace-net/src/host.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,15 @@ use crate::protocol::*;
2727
pub struct PeerConfig {
2828
/// Peers to connect to (used for both gossip and DHT bootstrap).
2929
pub peers: Vec<EndpointId>,
30-
/// Gossip topic name (None = no gossip, serve-only).
31-
pub gossip_topic: Option<String>,
30+
/// Whether to subscribe to live HEAD-update gossip. The topic id
31+
/// is the team root pubkey's 32 bytes — every team has exactly
32+
/// one gossip mesh, derived from its identity. `false` = serve-
33+
/// /pull-only (no subscription, no broadcasts).
34+
pub gossip: bool,
3235
/// The team root public key — verifies all incoming capability
3336
/// chains. Every connection's first stream must present a cap that
3437
/// chains back to this key. See `triblespace_core::repo::capability`.
38+
/// When `gossip = true`, also serves as the gossip topic id.
3539
pub team_root: ed25519_dalek::VerifyingKey,
3640
/// Pubkeys whose capabilities are revoked. Cascades transitively
3741
/// through the chain.
@@ -386,13 +390,15 @@ async fn host_loop(
386390

387391
// Gossip.
388392
let mut gossip_sender: Option<GossipSender> = None;
389-
if let Some(topic_name) = config.gossip_topic {
393+
if config.gossip {
390394
let gossip = Gossip::builder().spawn(ep.clone());
391395
router_builder = router_builder.accept(iroh_gossip::ALPN, gossip.clone());
392396

393-
let topic_id = iroh_gossip::TopicId::from_bytes(
394-
*blake3::hash(topic_name.as_bytes()).as_bytes()
395-
);
397+
// Topic id is the team root pubkey directly: the team root is
398+
// already 32 uniform bytes (an ed25519 pubkey), so no hashing
399+
// is needed. One gossip mesh per team — knowing the team
400+
// identifies the rendezvous channel.
401+
let topic_id = iroh_gossip::TopicId::from_bytes(config.team_root.to_bytes());
396402
// Always use subscribe (non-blocking). The join happens in the background
397403
// as peers come online. subscribe_and_join blocks until at least one peer
398404
// is reachable, which causes hangs if peers start at different times.

triblespace-net/src/peer.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
//!
1616
//! Use [`track`](Peer::track) to start tracking a remote branch from a
1717
//! specific peer (the `pile net pull` workflow), and [`fetch`](Peer::fetch)
18-
//! for single-blob pulls. Set `gossip_topic: None` in [`PeerConfig`] for
18+
//! for single-blob pulls. Set `gossip: false` in [`PeerConfig`] for
1919
//! pull-only mode where the peer doesn't subscribe to a flood mesh.
2020
2121
use std::collections::HashMap;
@@ -73,7 +73,7 @@ pub use crate::host::PeerConfig;
7373
/// let pile: Pile<Blake3> = Pile::open(Path::new("./team.pile")).unwrap();
7474
/// let peer = Peer::new(pile, key.clone(), PeerConfig {
7575
/// peers: vec![], // bootstrap nodes
76-
/// gossip_topic: Some("my-team".into()), // None = serve-only mode
76+
/// gossip: true, // false = serve/pull-only
7777
/// team_root: key.verifying_key(), // single-user fallback
7878
/// revoked: HashSet::new(),
7979
/// self_cap: [0u8; 32],
@@ -141,8 +141,8 @@ where
141141
/// reachable from its head and materialize a local tracking branch.
142142
///
143143
/// Used by `pile net pull` and other "go get this from over there"
144-
/// workflows. Does not require `gossip_topic` to be set — works in
145-
/// pull-only mode too. The fetched data lands in the wrapped store
144+
/// workflows. Does not require `gossip = true` — works in pull-
145+
/// only mode too. The fetched data lands in the wrapped store
146146
/// via the same auto-drain path that `refresh` uses.
147147
///
148148
/// Fire-and-forget: returns immediately. Use [`pull_branch`](Self::pull_branch)

0 commit comments

Comments
 (0)