feat(frame): additive threading — ThreadID, ReplyTo + REACT type

Chat bots need replies, threads and reactions. Add two optional, omitempty
envelope fields (ThreadID, ReplyTo) plus a REACT frame type. The fields ride the
cleartext envelope (message-id references, not secret content) and are omitted
when unset, so non-threaded frames are byte-for-byte identical on the wire and
their signatures unchanged — a non-breaking, additive change.

Client gains PublishReply (threaded reply) and React (emoji reaction). The
reaction content travels in the payload, so it is sealed like any message and
stays confidential in E2E rooms; receivers dispatch on Frame.Type == REACT and
read Frame.ReplyTo for the target. Publish is refactored to share one
publishFrame path with the new helpers; its behavior is unchanged.

Tests: frame round-trip of a threaded REACT frame (golden), non-threaded
wire/sig back-compat asserting thr/re keys are absent (edge), Unmarshal of
garbage errors (error path), and an end-to-end reply+reaction round-trip in an
encrypted ModeMatrix room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:10:44 +02:00
parent b2e6712dd2
commit 22092834bd
4 changed files with 227 additions and 18 deletions
+20 -9
View File
@@ -36,6 +36,10 @@ const (
KICK
// ACK acknowledges receipt of a previous frame.
ACK
// REACT is a reaction to a previous message (an emoji/shortcode). The target
// message id travels in ReplyTo; the reaction content rides Payload, so in
// encrypted rooms the reaction is sealed exactly like any other message.
REACT
)
// BlobRef references an out-of-band encrypted blob stored in the object store.
@@ -47,16 +51,23 @@ type BlobRef struct {
}
// Frame is the unit of transport on the unibus message bus.
//
// Threading metadata (ThreadID, ReplyTo) is additive and optional: it travels in
// the cleartext envelope (these are message-id references, not secret content)
// and is omitted entirely when unset, so the wire format and signatures of
// non-threaded frames are byte-for-byte identical to before this field existed.
type Frame struct {
Type FrameType `json:"t"`
Subject string `json:"s"`
Sender string `json:"from"` // endpoint id = EndpointID(signPub)
MsgID string `json:"id"` // ULID
Epoch int `json:"e"` // epoch of the room key K used to encrypt
Nonce []byte `json:"n,omitempty"` // AEAD nonce (encrypted rooms only)
Payload []byte `json:"p,omitempty"` // AEAD ciphertext (or cleartext if the room does not encrypt)
Blob *BlobRef `json:"b,omitempty"`
Sig []byte `json:"sig,omitempty"` // Ed25519 signature over SigningBytes()
Type FrameType `json:"t"`
Subject string `json:"s"`
Sender string `json:"from"` // endpoint id = EndpointID(signPub)
MsgID string `json:"id"` // ULID
Epoch int `json:"e"` // epoch of the room key K used to encrypt
ThreadID string `json:"thr,omitempty"` // root message id of the thread this frame belongs to
ReplyTo string `json:"re,omitempty"` // message id this frame replies to / reacts to
Nonce []byte `json:"n,omitempty"` // AEAD nonce (encrypted rooms only)
Payload []byte `json:"p,omitempty"` // AEAD ciphertext (or cleartext if the room does not encrypt)
Blob *BlobRef `json:"b,omitempty"`
Sig []byte `json:"sig,omitempty"` // Ed25519 signature over SigningBytes()
}
// Marshal serializes the frame to its wire representation (JSON in v1).
+62
View File
@@ -2,6 +2,7 @@ package frame
import (
"bytes"
"strings"
"testing"
)
@@ -40,6 +41,67 @@ func TestMarshalUnmarshalRoundTrip(t *testing.T) {
}
}
// TestThreadingRoundTrip (golden) verifies that the additive threading fields
// survive a marshal/unmarshal cycle and that a REACT frame keeps its target.
func TestThreadingRoundTrip(t *testing.T) {
orig := Frame{
Type: REACT,
Subject: "room.general",
Sender: "abc123",
MsgID: "01J000000000000000000002",
Epoch: 1,
ThreadID: "01J000000000000000000000",
ReplyTo: "01J000000000000000000001",
Payload: []byte("👍"),
}
b, err := orig.Marshal()
if err != nil {
t.Fatalf("Marshal: %v", err)
}
got, err := Unmarshal(b)
if err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if got.Type != REACT {
t.Fatalf("type mismatch: got %d want REACT(%d)", got.Type, REACT)
}
if got.ThreadID != orig.ThreadID || got.ReplyTo != orig.ReplyTo {
t.Fatalf("threading fields lost: got thr=%q re=%q", got.ThreadID, got.ReplyTo)
}
if !bytes.Equal(got.Payload, orig.Payload) {
t.Fatalf("reaction payload mismatch: got %q", got.Payload)
}
}
// TestNonThreadedWireBackCompat (edge) asserts that a frame without threading
// metadata serializes with NO thr/re keys at all, so its bytes — and therefore
// its signature — are identical to a pre-threading frame. This is the
// guarantee that makes the new fields a non-breaking, additive change.
func TestNonThreadedWireBackCompat(t *testing.T) {
f := Frame{Type: PUB, Subject: "room.general", Sender: "x", MsgID: "id", Epoch: 2, Payload: []byte("hi")}
b, err := f.Marshal()
if err != nil {
t.Fatalf("Marshal: %v", err)
}
s := string(b)
if strings.Contains(s, "\"thr\"") || strings.Contains(s, "\"re\"") {
t.Fatalf("threading keys leaked into a non-threaded frame: %s", s)
}
// SigningBytes of a non-threaded frame must also be free of the keys, so old
// signatures over equivalent frames still verify.
if sb := f.SigningBytes(); strings.Contains(string(sb), "\"thr\"") || strings.Contains(string(sb), "\"re\"") {
t.Fatalf("threading keys leaked into SigningBytes: %s", sb)
}
}
// TestUnmarshalRejectsGarbage (error path) verifies that malformed wire bytes
// surface as an error rather than a silently zero-valued frame.
func TestUnmarshalRejectsGarbage(t *testing.T) {
if _, err := Unmarshal([]byte("{not valid json")); err == nil {
t.Fatalf("expected error unmarshaling garbage, got nil")
}
}
func TestEndpointIDDeterministic(t *testing.T) {
pub := []byte("some-ed25519-public-key-bytes-32")
a := EndpointID(pub)