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.