# Joining Groups
The `GroupBuilder` uses a **typestate pattern** to ensure groups are configured correctly at compile time. You cannot call `join()` until both storage and state machine are set (or you use the shorthand `join()` which defaults to `NoOp`).
## Builder Flow
```
groups.with_key(key)
├── .join() // NoOp machine, InMemoryLogStore
├── .require_ticket(validator) // ticket-based peer auth (any stage)
└── .with_state_machine(machine)
├── .join() // InMemoryLogStore (default)
├── .require_ticket(validator)
├── .with_consensus_config(c)
│ └── .join()
└── .with_log_storage(store)
└── .join()
```
## Minimal Join (NoOp)
Useful for leader election without application logic:
```rust,ignore
let group = network.groups().with_key(key).join();
```
This creates a group with:
- `NoOp` state machine (commands are `()`, queries are `()`)
- `InMemoryLogStore` for storage
- Default `ConsensusConfig`
## With Custom State Machine
```rust,ignore
let group = network.groups()
.with_key(key)
.with_state_machine(MyStateMachine::new())
.join();
```
The state machine must be set **before** storage, since the storage type depends on the command type.
## With Custom Storage
```rust,ignore
let group = network.groups()
.with_key(key)
.with_state_machine(MyStateMachine::new())
.with_log_storage(MyDurableStore::new())
.join();
```
Storage must implement `Storage<M::Command>`.
## GroupKey
A `GroupKey` is the shared secret that all members must possess:
```rust,ignore
use mosaik::groups::GroupKey;
// Generate a new random key
let key = GroupKey::generate();
// All members use the same key
// The key can be serialized and distributed securely
```
## ConsensusConfig
All consensus parameters are part of `GroupId` derivation — every member must use the same values.
```rust,ignore
use mosaik::groups::ConsensusConfig;
let config = ConsensusConfig::builder()
.with_heartbeat_interval(Duration::from_millis(500)) // default
.with_heartbeat_jitter(Duration::from_millis(150)) // default
.with_max_missed_heartbeats(10) // default
.with_election_timeout(Duration::from_secs(2)) // default
.with_election_timeout_jitter(Duration::from_millis(500))// default
.with_bootstrap_delay(Duration::from_secs(3)) // default
.with_forward_timeout(Duration::from_secs(2)) // default
.with_query_timeout(Duration::from_secs(2)) // default
.build()?;
let group = network.groups()
.with_key(key)
.with_state_machine(machine)
.with_consensus_config(config)
.join();
```
### Parameters
| `heartbeat_interval` | 500ms | Interval between bond heartbeat pings |
| `heartbeat_jitter` | 150ms | Max random jitter subtracted from heartbeat interval |
| `max_missed_heartbeats` | 10 | Missed heartbeats before bond is considered dead |
| `election_timeout` | 2s | Base timeout before a follower starts an election |
| `election_timeout_jitter` | 500ms | Max random jitter added to election timeout |
| `bootstrap_delay` | 3s | Wait time before first election to allow peer discovery |
| `forward_timeout` | 2s | Timeout for forwarding commands to the leader |
| `query_timeout` | 2s | Timeout for strong-consistency query responses |
### Leadership Preference
Nodes can deprioritize leadership to prefer being followers:
```rust,ignore
// 3x longer election timeout (default multiplier)
let config = ConsensusConfig::default().deprioritize_leadership();
// Custom multiplier
let config = ConsensusConfig::default().deprioritize_leadership_by(5);
```
This multiplies both `election_timeout` and `bootstrap_delay`, reducing the chance of becoming leader.
## Ticket-Based Peer Authentication
By default, any peer that knows the `GroupKey` can join a group. To add an
extra layer of verification, call `.require_ticket()` with a
`TicketValidator` implementation. During bonding, each peer's discovery
tickets are checked against the validator -- only peers carrying a valid
ticket of the expected class are allowed to form bonds.
```rust,ignore
use mosaik::tickets::{Jwt, Hs256};
let validator = Jwt::with_key(Hs256::new(secret))
.allow_issuer("my-app");
let group = network.groups()
.with_key(GroupKey::from(&validator))
.with_state_machine(MyStateMachine::new())
.require_ticket(validator)
.join();
```
For asymmetric keys (e.g. ECDSA P-256):
```rust,ignore
use mosaik::tickets::{Jwt, Es256};
// Compressed P-256 public key (33 bytes, SEC1 format)
let validator = Jwt::with_key(Es256::hex("02abcd..."))
.allow_issuer("my-app");
let group = network.groups()
.with_key(GroupKey::from(&validator))
.with_state_machine(MyStateMachine::new())
.require_ticket(validator)
.join();
```
### Key points
- **Affects `GroupId` derivation.** Each validator's `signature()` is mixed
into the group ID. All members must use the same validators in the same
order, or they will derive different group IDs and never bond.
- **Peers must attach tickets via discovery.** Each peer calls
`network.discovery().add_ticket(ticket)` with a `Ticket` whose `class`
matches the validator's `class()`. See
[Auth Tickets](../discovery/tickets.md) for details.
- **Revocation is automatic.** If a bonded peer removes its ticket (or the
ticket expires and fails re-validation), the bond is terminated.
- **Expiration-aware bonds.** When `validate` returns
`Ok(Expiration::At(time))`, the bond worker schedules a timer. When the
ticket expires, the peer's ticket is re-validated automatically. If
re-validation fails, the bond is terminated with `NotAllowed`. Peers can
refresh their ticket by publishing a new one via discovery — the bond
worker picks up the updated expiration on the next peer entry update.
- **`GroupKey` can be derived from the validator.** Instead of manually
generating a key, you can use `GroupKey::from(&validator)` to derive a
deterministic key from the validator's `signature()`.
- **Accepts `Box<dyn TicketValidator>` and `Arc<dyn TicketValidator>`** in
addition to concrete types.
## Idempotent Joins
If you `join()` a group whose `GroupId` already exists on this node, the existing `Group` handle is returned. No duplicate worker is spawned.
## Lifecycle
When a `Group<M>` handle is dropped:
1. Bonds notify peers of the departure
2. The group's cancellation token is triggered
3. The group is removed from the active groups map