Spaces:
Running on Zero
Running on Zero
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.
|