HearthNet-Nemotron / docs /p2_p3 /M25-group-chat.md
Chris4K's picture
p2, p3
70650b7
|
Raw
History Blame
13.8 kB

M25 β€” Group Chat

Spec version: v2.0 Depends on: M10 Chat 1:1, M23 E2E Encryption, M16 Capability Tokens, M03 Capability Bus, X02 Event Log Depended on by: UI (web + M22 mobile), M14 Federation (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

@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

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

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.

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)
  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).
  • 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

[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


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.