Spaces:
Running on Zero
Running on Zero
File size: 13,767 Bytes
70650b7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | # M25 β Group Chat
**Spec version:** v2.0
**Depends on:** [M10 Chat 1:1](../../modules/M10-chat.md), [M23 E2E Encryption](M23-e2e-encryption.md), [M16 Capability Tokens](M16-tokens.md), [M03 Capability Bus](../../modules/M03-capability-bus.md), [X02 Event Log](../../cross-cutting/X02-events.md)
**Depended on by:** UI (web + M22 mobile), [M14 Federation](M14-federation.md) (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
```python
@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`
```python
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`
```python
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.
```python
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:
1. Caller emits `chat.thread.created` event into the event log with:
- `thread_id` (newly minted ULID)
- initial member list
- `e2e_enabled` flag
- if e2e_enabled: a freshly generated `ratchet_root_pubkey` and a per-member encrypted **sender key** payload (see [M23 Β§6.3](M23-e2e-encryption.md))
2. Each member's node sees the event arrive, decrypts the sender key payload addressed to itself, and constructs the GroupSession.
3. 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:
1. Verify caller is in `Thread.members`.
2. 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.
3. Emit `chat.thread.message.sent` event.
4. The event reaches all members (regular event-log propagation, no thread-specific transport).
5. Each member's GroupSession decrypts; the message appears in their UI.
### 4.3 Membership changes
#### Adding a member
1. Any existing member can issue `chat.thread.add_member` (Phase 2; later phases may add policies like "only admin can add").
2. 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.added` event.
3. 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`):
1. Emit `chat.thread.member.removed`.
2. The remaining members rekey the GroupSession (similar to add but excluding the removed member). New messages are not readable by the removed member.
3. 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](M14-federation.md)).
- 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_id` field 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 * 1024` for the cleartext body.
- `THREAD_RATE_LIMIT_PER_SENDER_PER_MINUTE = 60` (anti-spam, enforced by ThreadService).
- Beyond these β `bad_request` or `too_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
```toml
[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.created` payload
- 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.created` event 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.sent` event 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](../CAPABILITY_CONTRACT_v2.md)
- Encryption primitives: [M23 Β§6 sender keys](M23-e2e-encryption.md)
- Event types: [CAPABILITY_CONTRACT_v2 Β§7.1](../CAPABILITY_CONTRACT_v2.md#71-new-event-types)
- Federation: [M14 Β§6](M14-federation.md)
---
## 9. Open questions
1. **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?")
2. **Per-thread retention policy** β currently inherits the community-wide retention. Different threads might want different policies (planning thread = 30 days, household chat = forever).
3. **Read-only threads** (announcements) β pseudo-thread where only one member can send. Worth a flag or worth a dedicated capability?
4. **Thread search** β could plug into `rag.*`. Indexing of decrypted message text would be opt-in per thread; raises privacy concerns.
5. **Cross-thread mentions / linking** β e.g. "see thread X for context". Probably as a UI affordance (markdown link), not a protocol feature.
6. **Disappearing messages** β Signal-style auto-expiry per-thread. Useful for sensitive coordination; adds complexity. Phase 3 candidate.
|