File size: 9,768 Bytes
6f9a5fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
295
296
# M10 β€” Chat Service

**Spec version:** v1.0
**Depends on:** M01 (identity, for signing), M03 (bus), X02 (events), X04 (config), X03 (observability), M07 (attachments via blobs)
**Depended on by:** M08 (UI chat tab)

---

## 1. Responsibility

Provide `chat.send` and `chat.history` capabilities. Handle direct-message delivery: directly if recipient is online; via store-and-forward through an anchor if offline. Maintain a per-peer chat view materialised from `chat.message.*` events.

E2E encryption between users is Phase 2 ([CONTRACT Β§12 open question 1](../CAPABILITY_CONTRACT.md)). MVP relies on TLS-in-transit + signed-at-rest within a trusted community.

---

## 2. File layout

```
hearthnet/services/chat/
β”œβ”€β”€ __init__.py
β”œβ”€β”€ service.py        # ChatService
β”œβ”€β”€ delivery.py       # DeliveryManager: direct vs store-and-forward
└── views.py          # ChatView: MaterialisedView
```

---

## 3. Public API

### 3.1 `views.py`

```python
# hearthnet/services/chat/views.py
@dataclass(frozen=True)
class ChatMessage:
    event_id:     str
    lamport:      int
    sender:       str           # NodeID full form
    recipient:    str
    body:         str
    attachments:  list[dict]    # [{cid, name}]
    created_at:   str
    delivered_at: str | None
    read_at:      str | None

class ChatView:
    """MaterialisedView from chat.message.sent / .delivered / .read events."""

    def __init__(self, our_node_id_full: str):
        ...

    # MaterialisedView protocol:
    def reset(self) -> None: ...
    def apply(self, event: Event) -> None: ...
    def snapshot_state(self) -> dict: ...
    def restore_state(self, state: dict) -> None: ...

    # queries:
    def history_with(
        self,
        peer: str | None = None,
        *,
        since_lamport: int = 0,
        limit: int = 200,
    ) -> list[ChatMessage]: ...
    def peers(self) -> list[str]:
        """All NodeIDs we have exchanged messages with."""
    def unread_count(self, peer: str) -> int: ...
```

### 3.2 `delivery.py`

```python
# hearthnet/services/chat/delivery.py
class DeliveryManager:
    """Decides direct vs store-and-forward; performs delivery attempts."""

    def __init__(
        self,
        bus: CapabilityBus,
        event_log: EventLog,
        author_kp: KeyPair,
        peer_registry: PeerRegistry,
        config: ChatConfig,
    ):
        ...

    async def deliver(self, message_event: Event) -> str:
        """Attempt delivery.
        Returns: 'direct' | 'forwarded' | 'queued'.
        Strategy:
          1. Look up recipient in peer_registry.
          2. If online and reachable: push via pubsub topic chat.message.<recipient_short>.
          3. Else: pick 2 anchors with chat.store_and_forward capability (Phase 2),
                   call them with the encrypted-blob carrying message.
                   Fall back to leaving it in our log for eventual sync.
          4. Mark with method."""

    async def on_local_message_arrived(self, message_event: Event) -> None:
        """When we receive a chat.message.sent event addressed to us:
           - emit pubsub chat.message.<our_short_id> for UI
           - append chat.message.delivered event"""

    async def on_pubsub_message(self, payload: dict) -> None:
        """When the pubsub topic delivers a message to us, process it
           (which may include appending the event to our log if not already there)."""
```

### 3.3 `service.py`

```python
# hearthnet/services/chat/service.py
class ChatService:
    name    = "chat"
    version = "1.0"

    def __init__(
        self,
        config: ChatConfig,
        bus: CapabilityBus,
        event_log: EventLog,
        replay_engine: ReplayEngine,
        peer_registry: PeerRegistry,
        author_kp: KeyPair,
        our_node_id_full: str,
    ):
        self.view = ChatView(our_node_id_full=our_node_id_full)
        replay_engine.register(
            "chat",
            self.view,
            event_types=["chat.message.sent", "chat.message.delivered", "chat.message.read"],
        )
        self.delivery = DeliveryManager(bus, event_log, author_kp, peer_registry, config)

    def capabilities(self) -> list[tuple[CapabilityDescriptor, Callable, ParamsPredicate]]:
        """Registers: chat.send, chat.history."""

    async def start(self) -> None:
        """Replay; subscribe to pubsub topic chat.message.<our_short_id>."""

    async def stop(self) -> None: ...
    def health(self) -> dict: ...

    # --- handlers ---

    async def handle_send(self, req: RouteRequest) -> dict:
        """CONTRACT Β§4.15.
        1. Idempotency by (author, client_id).
        2. Append chat.message.sent event.
        3. DeliveryManager.deliver(event) β†’ returns delivery method.
        4. Return {event_id, lamport, delivered}."""

    async def handle_history(self, req: RouteRequest) -> dict:
        """CONTRACT Β§4.16. Self-only: refuses calls where caller != our node_id."""
```

### 3.4 Capability descriptors

```python
descriptor_send = CapabilityDescriptor(
    name="chat.send", version=(1, 0), stability="stable",
    request_schema={...}, response_schema={...}, stream_schema=None,
    params={}, max_concurrent=8,
    trust_required="member", timeout_seconds=15, idempotent=True,
)

descriptor_history = CapabilityDescriptor(
    name="chat.history", version=(1, 0), stability="stable",
    request_schema={...}, response_schema={...}, stream_schema=None,
    params={}, max_concurrent=8,
    trust_required="self",          # the bus enforces caller == our_node_id
    timeout_seconds=5, idempotent=True,
)
```

The `trust_required="self"` is a new level introduced by chat. The bus interprets it as: only the local UI calling through localhost may invoke this capability. Remote callers receive `unauthorized`.

---

## 4. Behaviour

### 4.1 Send β†’ delivery sequence

```
UI β†’ bus.call("chat.send", (1,0), {input: {client_id, recipient, body, attachments}})
  β†’ ChatService.handle_send
       β†’ idempotency check
       β†’ event_log.append_local("chat.message.sent", data, author_kp)
            ↳ this fans out to ReplayEngine which calls ChatView.apply()
       β†’ DeliveryManager.deliver(event)
            ↳ if recipient online:
                 β†’ publish to pubsub topic chat.message.<recipient_short>
            ↳ else (Phase 2):
                 β†’ bus.call("chat.forward", ...) on two anchors with the capability
            ↳ else:
                 β†’ noop, will sync via X02 eventually
       β†’ return {event_id, lamport, delivered: "direct"|"forwarded"|"queued"}
```

### 4.2 Receive sequence

```
pubsub topic chat.message.<our_short_id> fires with event payload
  β†’ DeliveryManager.on_pubsub_message
       β†’ event_log.append_received(event)            (deduplicated by event_id)
            ↳ ChatView.apply()
       β†’ event_log.append_local("chat.message.delivered", {target_event_id}, our_kp)
            ↳ propagated back to sender via gossip
       β†’ emit local notification (UI hook)
```

### 4.3 Read receipts (optional)

When the UI scrolls past a message, it appends `chat.message.read`. If `config.chat.read_receipts_enabled = false`, the UI doesn't emit it.

### 4.4 Store-and-forward (Phase 2 stub)

MVP path: if recipient offline, message stays in our log; recipient gets it when they sync. This is fine for community members on the same LAN where everyone gossips.

Phase 2: a separate `chat.forward.put@1.0` capability registered by anchors. Sender ships the event to 2 anchors. When recipient reappears, they probe `chat.forward.fetch@1.0` against anchors. After successful delivery, anchors drop the cached event.

### 4.5 Attachments

`attachments` is a list of `{cid, name}`. The actual blob is sent separately via [M07](M07-file-blobs.md). The chat event only references the CID. Receivers fetch on demand.

### 4.6 No self-chat

`recipient == our_node_id` is rejected with `bad_request`.

### 4.7 Group chat (Phase 2)

Reserved `chat.thread.*` namespace. Out of scope here.

---

## 5. Errors

| Condition | Wire code |
|-----------|-----------|
| recipient not a community member | `not_found` |
| caller calling history but not localhost | `unauthorized` |
| empty body and no attachments | `bad_request` |
| attachment CID not known to recipient | (silent; recipient fetches via M07 on read) |

---

## 6. Configuration

From [X04 Β§3](../cross-cutting/X04-config.md):

```python
config.chat.enabled
config.chat.store_and_forward   # Phase 2 flag
```

Phase 2: `config.chat.read_receipts_enabled`.

---

## 7. Tests

### Unit
- `test_send_appends_event_and_returns_id`
- `test_send_idempotent_on_client_id`
- `test_history_rejects_remote_caller`
- `test_view_apply_updates_state`
- `test_self_chat_rejected`

### Integration
- `test_two_node_direct_delivery`
- `test_recipient_offline_then_online_sees_message_after_sync`
- `test_delivered_event_propagates_back_to_sender`

---

## 8. Cross-references

| What | Where |
|------|-------|
| `chat.*` wire | [CONTRACT Β§4.15–4.16](../CAPABILITY_CONTRACT.md) |
| Event types | [CONTRACT Β§7.2](../CAPABILITY_CONTRACT.md) |
| Event log | [X02](../cross-cutting/X02-events.md) |
| Attachments | [M07](M07-file-blobs.md) |
| Pubsub topics | [CONTRACT Β§8](../CAPABILITY_CONTRACT.md), [X01 Β§8](../cross-cutting/X01-transport.md) |
| UI chat tab | [M08 Β§5.3](M08-ui.md) |

---

## 9. Open questions

1. **E2E encryption** β€” Phase 2. Will use X25519 + ChaCha20-Poly1305. Body encrypted; envelope (sender, recipient, lamport) stays cleartext. Signature still over ciphertext.
2. **Group chat** β€” Phase 2.
3. **Voice notes** β€” Phase 2, would use `stt.*` for transcript, blob for audio.
4. **Phone notifications** β€” Phase 2, requires the relay tier.