Spaces:
Running on Zero
M25 β Group Chat
Spec version: v2.0 Depends on: M10 Chat 1:1, M23 E2E Encryption, M16 Capability Tokens, M03 Capability Bus, X02 Event Log Depended on by: UI (web + M22 mobile), M14 Federation (cross-community threads)
1. Responsibility
Multi-party threaded conversations with the same guarantees as 1:1 chat: end-to-end encryption (optional but default on), event-log-anchored history, no central server required, members can come and go.
A thread is a long-lived object identified by a ULID. It has an authoritative member list maintained in the event log, an encryption "group session" (M23 sender keys), and message history. Threads do not currently support reactions, replies, threading-within-thread, or rich content β those are explicit non-goals for Phase 2 and may arrive in Phase 3 once usage informs design.
Group threads are the substrate the Nachbarschaftshilfe use case wants: a Sankt-Martins-ComitΓ© planning thread, a "Wer hat Werkzeug?" workshop thread, a household coordination thread between Christof, Jana, and grandparents.
2. File layout
hearthnet/services/chat/
βββ thread_service.py # ThreadService β capability registration & dispatch
βββ thread_views.py # Materialised views: thread list, member list, history
βββ thread_store.py # Read-only projections; not the source of truth
βββ group_session.py # Wraps M23 sender keys for a thread
βββ moderation.py # Phase-2: remove-member, archive β minimal
3. Public API
3.1 Dataclasses
@dataclass(frozen=True)
class Thread:
thread_id: ThreadID
name: str
created_at: datetime
created_by: NodeID
members: frozenset[NodeID]
e2e_enabled: bool
ratchet_root: str | None # x25519 pubkey of group session root, None if cleartext
archived: bool
@dataclass(frozen=True)
class ThreadMessage:
event_id: EventID
thread_id: ThreadID
client_id: ClientID
sender: NodeID
sent_at: datetime
body: str | None # cleartext if e2e_enabled=False
encrypted: EncryptedPayload | None
attachments: list[Attachment]
delivered_to: frozenset[NodeID] # tracked via chat.thread.message.delivered events
3.2 ThreadService
class ThreadService:
"""Capability handlers for chat.thread.*"""
def __init__(
self,
bus: CapabilityBus,
event_log: EventLog,
identity: Identity,
encryption: EncryptionService, # M23
view_store: ThreadViewStore,
observability: Observability,
): ...
async def start(self) -> None:
# Registers: chat.thread.create, .send, .history, .leave, .add_member, .archive
...
# --- handlers (selected) ---
async def create(self, body: CreateThreadBody) -> CreateThreadResult: ...
async def send(self, body: SendThreadBody) -> SendThreadResult: ...
async def history(self, body: HistoryBody) -> HistoryResult: ...
async def leave(self, body: LeaveBody) -> LeaveResult: ...
async def add_member(self, body: AddMemberBody) -> AddMemberResult: ...
async def archive(self, body: ArchiveBody) -> ArchiveResult: ...
3.3 ThreadViewStore
class ThreadViewStore:
"""Read model. Backed by SQLite; rebuilt from the event log on cold start."""
def list_for_member(self, node_id: NodeID) -> list[Thread]: ...
def get_thread(self, thread_id: ThreadID) -> Thread | None: ...
def get_messages(self, thread_id: ThreadID, since_lamport: int = 0, limit: int = 200) -> list[ThreadMessage]: ...
def members_of(self, thread_id: ThreadID) -> frozenset[NodeID]: ...
# Internal β subscribed to the event log:
async def apply(self, event: Event) -> None: ...
3.4 GroupSession
Thin wrapper around M23 sender keys; one per thread.
class GroupSession:
def __init__(self, thread_id: ThreadID, ratchet: SenderKeyRatchet): ...
def encrypt(self, plaintext: bytes) -> EncryptedPayload: ...
def decrypt(self, sender: NodeID, payload: EncryptedPayload) -> bytes: ...
def rekey(self) -> None: ...
def add_member(self, new_member: NodeID, their_identity_pubkey: bytes) -> None: ...
def remove_member(self, leaving_member: NodeID) -> None: ...
4. Behaviour
4.1 Thread creation
chat.thread.create@1.0 flow:
- Caller emits
chat.thread.createdevent into the event log with:thread_id(newly minted ULID)- initial member list
e2e_enabledflag- if e2e_enabled: a freshly generated
ratchet_root_pubkeyand a per-member encrypted sender key payload (see M23 Β§6.3)
- Each member's node sees the event arrive, decrypts the sender key payload addressed to itself, and constructs the GroupSession.
- The view store materialises a new Thread row.
If any member is offline at creation, they will receive the event when they next sync. Their GroupSession constructs lazily on first decrypt.
4.2 Sending
chat.thread.send@1.0 flow:
- Verify caller is in
Thread.members. - If
e2e_enabled, encrypt body with the GroupSession's current sender key. The ciphertext is opaque to the event log β even other community members who are not in the thread cannot read it. - Emit
chat.thread.message.sentevent. - The event reaches all members (regular event-log propagation, no thread-specific transport).
- Each member's GroupSession decrypts; the message appears in their UI.
4.3 Membership changes
Adding a member
- Any existing member can issue
chat.thread.add_member(Phase 2; later phases may add policies like "only admin can add"). - The caller's GroupSession is rekeyed: a new sender key is generated, encrypted under each existing member's pubkey and the new member's pubkey, and emitted in the
chat.thread.member.addedevent. - The new member cannot read prior messages β they joined at the new epoch. (This is by design and standard for sender-key group encryption: forward-secrecy is preserved.) Old messages remain encrypted with the old sender key, which the new member never sees.
Removing a member
chat.thread.remove_member (or self-leave via chat.thread.leave):
- Emit
chat.thread.member.removed. - The remaining members rekey the GroupSession (similar to add but excluding the removed member). New messages are not readable by the removed member.
- The removed member's UI marks the thread as "you left" and stops decrypting incoming messages. Their event log still contains old messages they can still read; they just can't read new ones.
4.4 History
chat.thread.history@1.0:
- Self-only capability (you can only ask for history of threads you're a member of).
- Returns from local view store. No cross-node query needed β every member already has the events.
- Pagination by
since_lamport+limit. Messages return in logical (Lamport) order, not wall-clock order, to match what other members will see.
4.5 Read-receipts / delivery tracking
Each member's node emits chat.thread.message.delivered (lightweight, no payload beyond event_id reference) when they materialise a message. UI shows "delivered to 4/5" by counting these events. Optional β policy.chat.delivery_receipts_enabled (default true) controls whether they're emitted.
4.6 Archiving
chat.thread.archived is a soft state. Archived threads are hidden from the default thread list, no longer rekey on membership change, and no longer accept sends. Members can still read history. An archived thread can be unarchived by any member.
There is no "delete thread". Events are immutable. A thread that is archived and whose messages are all expired (via X02 retention policies) becomes effectively gone.
4.7 Attachments
attachments carry cid (blob CID) and name. The blob itself is uploaded via file.put separately. Members of the thread are by definition authorised to fetch the blob β the bus enforces this via a capability token issued automatically when sending an attachment in an E2E thread:
On send-with-attachment:
1. Service issues a short-lived (24h) token via M16 with:
scope.capabilities = ["file.fetch@1.0"]
scope.params_constraints.cid = [attachment.cid]
audience = thread.members (excluding self)
2. Token is included in the encrypted message body.
3. Recipients use the token when fetching the blob from whichever node holds it.
This avoids the "file is restricted but everyone in the thread should access it" coordination problem.
4.8 Federation of threads
A thread MAY include members from federated communities. Mechanics:
- The thread's
community_id(the one in event headers) is the creator's community. - Members from federated communities subscribe to the thread's events via the standard federation event-bridge (see M14 Β§6).
- Federated members are full participants β they can send, leave, be removed β provided the federation manifest grants
chat.thread.send@1.0. - The view store on a federated member's node carries the foreign-community thread alongside their local-community threads, distinguished by
Thread.community_idfield for UI purposes.
If federation is revoked, foreign members are silently removed from the thread on the next rekey.
4.9 Throughput and limits
THREAD_MAX_MEMBERS = 200(Phase-2 conservative; larger groups should be a different module).THREAD_MAX_MESSAGE_BYTES = 64 * 1024for the cleartext body.THREAD_RATE_LIMIT_PER_SENDER_PER_MINUTE = 60(anti-spam, enforced by ThreadService).- Beyond these β
bad_requestortoo_many_requests.
5. Errors
| Code | Cause |
|---|---|
bad_request |
Empty member list, malformed body, member list contains caller twice |
unauthorized |
Caller not a member of the thread (for send/history/leave/add) |
not_found |
thread_id unknown |
e2e_session_missing |
Caller has no GroupSession yet (sender keys not received) |
e2e_decrypt_failed |
Local key state corrupt; UI should prompt for a manual rekey |
too_many_requests |
Rate limit exceeded |
policy_violation |
E.g. trying to add member outside of federation scope |
6. Configuration
[services.chat.thread]
enabled = true
max_members = 200
max_message_bytes = 65536
rate_limit_per_sender_per_minute = 60
delivery_receipts_enabled = true
allow_federated_members = true
[services.chat.thread.archival]
auto_archive_after_days_idle = 0 # 0 = never auto-archive
7. Tests
7.1 Unit
- Create thread with 3 members; verify GroupSession is constructable by each member from the
chat.thread.createdpayload - Send + decrypt round-trip
- Add member; old messages remain undecryptable for them, new ones work
- Remove member; their session can't decrypt new messages
- Self-leave; cleanup is graceful (no orphan state)
- History pagination: 1000 messages, fetch 200 + 200 + 200... covers all
7.2 Integration
- Three nodes on one LAN form a thread; messages propagate via gossip
- Same with one member partitioned; their replay on reconnect works
- E2E on/off threads coexist; switching one to the other is not supported (must create a new thread)
- Federation: a federated peer's node receives the
chat.thread.createdevent via the bridge and constructs a working GroupSession
7.3 Adversarial
- A non-member tries to call
chat.thread.sendβunauthorized - A non-member subscribes to
chat.thread.message.<id>pubsub: receives encrypted blobs they can't decrypt (no information leak beyond traffic patterns and member list) - Replay: replaying an old
chat.thread.message.sentevent by IP-level adversary is rejected by per-message nonce in the E2E header - Rekey storm: 100 sequential add/remove operations finish within 30s on the dev rig; no deadlock
7.4 Performance
- 50-member thread, 1 msg/s: p95 deliver-to-decrypt latency < 500ms on LAN
- History fetch of 10,000 messages: < 2s on SSD
8. Cross-references
- Capability spec: CAPABILITY_CONTRACT_v2 Β§4.16β4.19
- Encryption primitives: M23 Β§6 sender keys
- Event types: CAPABILITY_CONTRACT_v2 Β§7.1
- Federation: M14 Β§6
9. Open questions
- Reactions / replies / rich content β explicitly out of Phase 2. Worth a survey of community use before designing. (Likely Phase 3 add-on, gated on "are people actually asking for it?")
- Per-thread retention policy β currently inherits the community-wide retention. Different threads might want different policies (planning thread = 30 days, household chat = forever).
- Read-only threads (announcements) β pseudo-thread where only one member can send. Worth a flag or worth a dedicated capability?
- Thread search β could plug into
rag.*. Indexing of decrypted message text would be opt-in per thread; raises privacy concerns. - Cross-thread mentions / linking β e.g. "see thread X for context". Probably as a UI affordance (markdown link), not a protocol feature.
- Disappearing messages β Signal-style auto-expiry per-thread. Useful for sensitive coordination; adds complexity. Phase 3 candidate.