feat: EXT-007 P2P message history gossip

After hello verification, the connecting peer sends history_request to
the first peer it meets (one per room, no fan-out). The responder queries
SQLite and replies with a history_chunk. Received history is stored via
INSERT OR IGNORE (mid dedup) and emitted as history_loaded IPC events.

- proto: MsgHistoryRequest/Chunk types, HistoryEntry, EvtHistoryLoaded,
  ComputeMsgID (sha256 content-addressed ID), MsgID field on ChatMessage
- store: ALTER TABLE ADD COLUMN msg_id + unique index migration (idempotent);
  RecentMessagesSince query (msg_id IS NOT NULL filter); msg_id persisted on save
- mesh: RequestHistoryFrom, HandleHistoryRequest, HandleHistoryChunk methods;
  historyRequested/historyFirstPeer state to ensure single-peer requests
- peer: dispatch history_request/history_chunk; RequestHistoryFrom after hello;
  stamp MsgID on incoming chat messages
- ipc: stamp MsgID on outgoing group chat messages
- EXTENSIONS.md: EXT-007 documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-28 23:08:17 +02:00
parent 1c73f1b1ef
commit 7c3cedc549
6 changed files with 322 additions and 35 deletions

View File

@@ -224,3 +224,83 @@ Each in-progress `.tmp` file has a corresponding `.tmp.meta` JSON sidecar:
The sidecar is written when the transfer starts and removed on completion or
corruption. Interrupted transfers keep the sidecar indefinitely.
---
## EXT-007 — P2P Message History Gossip
**Status:** implemented (daemon mode)
**Affects:** peer-to-peer wire (two new message types); IPC (new event)
### Motivation
When a peer joins a network for the first time (or reconnects after an
absence), they have no history. This extension lets them request recent
messages from an existing peer over the already-established encrypted
DataChannel, without involving the anchor.
### Wire messages
#### `history_request`
Sent by the newly-connected peer to the first peer whose hello is verified.
One request per room.
```json
{
"type": "history_request",
"room": "general",
"since": 1700000000000,
"limit": 200
}
```
| Field | Type | Description |
|---------|---------------|-------------|
| `room` | string | Room to request history for. |
| `since` | int64 (ms) | Only return messages with `ts > since`. 0 = return up to `limit` most recent. |
| `limit` | int (max 500) | Maximum messages to return. Responder may return fewer. |
#### `history_chunk`
```json
{
"type": "history_chunk",
"room": "general",
"history": [
{ "mid": "...", "from": "<peer-id>", "from_alias": "alice", "text": "hello", "ts": 1700000001000 }
],
"history_done": true
}
```
| Field | Type | Description |
|----------------|--------|-------------|
| `history` | array | Messages, oldest-first. |
| `history_done` | bool | Always `true` (single-chunk response). |
### Deduplication
`mid` is the deduplication key. The store uses `INSERT OR IGNORE` on `mid`,
so receiving a message twice (live or via gossip) is a no-op. Messages
without a `mid` are assigned one at receive time and are not gossipped.
### Behaviour
- The **receiver** sends one `history_request` per known room immediately
after hello verification with the **first** peer it connects to. Requesting
only the first peer avoids fan-out amplification.
- The **responder** queries its SQLite store and replies with a single
`history_chunk`. `limit` is capped at 500 server-side. Rate-limited to one
request per (peer, room) per 60 seconds.
- Received history messages are saved to the local store (`INSERT OR IGNORE`)
and emitted as `history_loaded` IPC events so the UI can display them.
### IPC event
```json
{ "type": "history_loaded", "room": "general", "messages": [...] }
```
Emitted once per room after a `history_chunk` is fully processed. The UI
should render these messages with a visual separator from live messages.