summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 12:57:51 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 12:57:51 +0200
commita86bced1640ac1a695b95ac68b95e3fc581e687d (patch)
treefe8ea8af945e139a2308eb19ad572e10a41b05e4
parent988a0eecd522431980174910db28e45a63ff2710 (diff)
downloadmeshbay-a86bced1640ac1a695b95ac68b95e3fc581e687d.tar.gz
refactor(chat): no thread_id — chat has no threads
Nothing ever set it (the page and the CLI sent null) and nothing read it (the node's get_thread had no caller). It is no longer sent by the page or the CLI, in clear or in the sealed payload, nor relayed or served by the node; get_thread and its index are gone, and the protocol and design docs no longer name it. The node still accepts a chat_msg carrying one, from the desktop apps and CLIs already installed, and drops it. The column stays, empty, in chat.db: a node downgraded to an older version names it in every query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md4
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md8
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py4
-rw-r--r--packages/meshbay-cli/tests/fake_node.py2
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js6
-rw-r--r--packages/meshbay-hub/tests/harness/chat_scroll_probe.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py32
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops/chat.py1
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py20
-rw-r--r--packages/meshbay-node/tests/test_chat_is_bounded.py18
-rw-r--r--packages/meshbay-node/tests/test_chat_store.py36
13 files changed, 70 insertions, 72 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 5325ac7..750484a 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2051,8 +2051,8 @@ message may cost, and **60 a minute per account per group** for how often one
member may impose it. Both are checked before anything is stored or relayed, and
a refusal names itself (`chat_too_large`, `chat_rate_limited`) and is audited.
-The numbers are meant to be invisible. The sealed payload is the text, a thread
-id, a display name and a timestamp — an attachment is a file on a root and
+The numbers are meant to be invisible. The sealed payload is the text, a display
+name and a timestamp — an attachment is a file on a root and
travels as a reference (§4.5) — so 64 KB is some sixty thousand characters, and
sixty a minute is far above a person typing. The rate is keyed by **account**,
not by connection: a second tab does not make anyone type faster, and keying on
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index b1f7644..04860cd 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1902,22 +1902,22 @@ read, and receivers include devices that were not connected when it was sent.
| |
|-- chat_msg {v, format: 1, epoch, device, |
| nonce, ct, sig, sender_name, |
- | [thread_id], [iteration]} -------------------->|
+ | [iteration]} --------------------------------->|
| | envelope checks (below)
| | persist to THIS group's store
| | broadcast ------------------->|
| | {chat_msg, sender_id,
| | sender_name, format, epoch,
| | device, nonce, ct, sig,
- | | thread_id, timestamp}
+ | | timestamp}
| | notify the hub: group id and
| | sender id only
|<- ack {v} -----------------------------------------|
|
|-- chat_hist {v, [before], [limit <= 200]} -------->|
|<- chat_hist_resp {v, has_more, messages: [{id, |
- | sender_id, sender_name, timestamp, thread_id, |
- | format, epoch, device, nonce, ct, sig}]} ------|
+ | sender_id, sender_name, timestamp, format, |
+ | epoch, device, nonce, ct, sig}]} --------------|
|
|<= chat_epoch_ack {v, epoch} =======================| pushed: a new epoch is open
```
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
index 587303a..d525abf 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/chat.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py
@@ -186,7 +186,7 @@ async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: st
ident = session.identity
device_b64 = ident.pk_ed
sealed = seal(key, session.group_id, epoch, device_b64, unb64(device_b64), ident.sk_ed,
- {"text": text, "thread_id": None, "sender_name": sender_name,
+ {"text": text, "sender_name": sender_name,
"sent_at": int(time.time())})
if len(sealed["ct"]) > MAX_CIPHERTEXT:
raise CliError(USAGE, "This message is too large for chat; send it as a file "
@@ -195,5 +195,5 @@ async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: st
"format": FORMAT_SEALED, "epoch": epoch,
"ct": sealed["ct"], "nonce": sealed["nonce"],
"device": unb64(device_b64), "sig": sealed["sig"],
- "thread_id": None, "sender_name": None}, "ack")
+ "sender_name": None}, "ack")
diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py
index 552bd39..c618ee4 100644
--- a/packages/meshbay-cli/tests/fake_node.py
+++ b/packages/meshbay-cli/tests/fake_node.py
@@ -140,7 +140,7 @@ class FakeNode:
def _store(self, device: bytes, epoch: int, sealed: dict, sender_id: str) -> None:
self.chat.append({"id": len(self.chat) + 1, "sender_id": sender_id,
"sender_name": "", "timestamp": 1_700_000_000 + len(self.chat),
- "thread_id": None, "format": 1, "epoch": epoch,
+ "format": 1, "epoch": epoch,
"device": device, "nonce": sealed["nonce"], "sig": sealed["sig"],
"payload": "", "ct": sealed["ct"]})
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index bb52742..8f7f7b9 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -497,7 +497,7 @@ const toHex = (u8) =>
def _chat_payload(idx: int) -> dict:
"""A distinct message per vector, so a crossed result cannot pass."""
- return {"text": f"message {idx} — café", "thread_id": None,
+ return {"text": f"message {idx} — café",
"sender_name": f"member-{idx}", "sent_at": 1_700_000_000 + idx}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index 1d61c43..34e86eb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -472,7 +472,7 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
setSending(true);
setInput('');
try {
- await transport.sendChat(text, 0, null, username);
+ await transport.sendChat(text, 0, username);
setMessages(prev => [...prev, {
id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
own: true,
@@ -480,7 +480,6 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
sender_name: username,
payload: text,
timestamp: Date.now() / 1000,
- thread_id: null,
}]);
jumpToBottom();
if (onActivity) onActivity();
@@ -515,11 +514,11 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
const structured = JSON.stringify({
text: '', attachment: { filename: storedAs, size: file.size, type: ftype },
});
- await transport.sendChat(structured, 0, null, username);
+ await transport.sendChat(structured, 0, username);
setMessages(prev => [...prev, {
id: `own-${Date.now()}-${Math.random().toString(36).slice(2)}`,
own: true, sender_id: userId || username, sender_name: username,
- payload: structured, timestamp: Date.now() / 1000, thread_id: null,
+ payload: structured, timestamp: Date.now() / 1000,
}]);
jumpToBottom();
} catch (err) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index 9ce07bc..3efbb22 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -138,7 +138,6 @@ extendTransport(class {
sender_id: row.sender_id,
sender_name: row.sender_name || '',
timestamp: row.timestamp,
- thread_id: row.thread_id,
};
const format = row.format || 0;
if (format === 0) {
@@ -169,7 +168,7 @@ extendTransport(class {
* post in clear into a group whose members believe their chat is encrypted;
* the node refuses it too, and the two refusals agreeing is the point.
*/
- async sendChat(text, iteration, threadId, senderName) {
+ async sendChat(text, iteration, senderName) {
const keys = await this.chatKeys();
const epoch = this.chatEpoch || keys.current;
const epochKey = keys.byEpoch.get(epoch);
@@ -182,7 +181,6 @@ extendTransport(class {
const gid = this._groupId || '';
const plaintext = msgpack_encode({
text: String(text),
- thread_id: threadId || null,
sender_name: senderName || '',
sent_at: Math.floor(Date.now() / 1000),
});
@@ -204,7 +202,6 @@ extendTransport(class {
nonce,
device,
sig,
- thread_id: threadId || null,
// Deliberately absent: the display name is inside the envelope now.
sender_name: null,
});
@@ -315,7 +312,6 @@ extendTransport(class {
...base,
payload: String(plain.text || ''),
sender_name: plain.sender_name || base.sender_name,
- thread_id: plain.thread_id ?? base.thread_id,
device: deviceB64,
verified: true,
trust,
diff --git a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
index 17ec554..5ef6c76 100644
--- a/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
+++ b/packages/meshbay-hub/tests/harness/chat_scroll_probe.py
@@ -65,7 +65,7 @@ for (let i = 0; i < N; i++) {
// grows the list the way the real thing does.
payload: (i % 5 === 0 ? 'see https://example.invalid/p/' + i + ' ' : '')
+ 'message number ' + i + ', long enough to give the bubble a height',
- timestamp: now - (N - i) * 60, thread_id: null,
+ timestamp: now - (N - i) * 60,
});
}
diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py
index 9e07aff..7610b33 100644
--- a/packages/meshbay-node/src/meshbay_node/chat/store.py
+++ b/packages/meshbay-node/src/meshbay_node/chat/store.py
@@ -54,8 +54,11 @@ CREATE TABLE IF NOT EXISTS messages (
sender_name TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp);
-CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
"""
+# `thread_id` is written and read by nothing: chat has no threads. The column
+# stays, empty, because a node downgraded to an older version names it in every
+# query, and its rows must keep opening. Its index goes; an older node makes it
+# again on its own.
# Additive, every one with a default, so an existing chat.db opens unchanged and
# a node that is downgraded still reads its own rows. `CREATE TABLE IF NOT
@@ -88,7 +91,6 @@ class StoredMessage:
iteration: int
payload: bytes
timestamp: float
- thread_id: str | None
sender_name: str = ""
format: int = FORMAT_PLAIN
epoch: int = 0
@@ -97,16 +99,16 @@ class StoredMessage:
sig: bytes | None = None
-_COLUMNS = ("id, sender_id, iteration, payload, timestamp, thread_id, "
+_COLUMNS = ("id, sender_id, iteration, payload, timestamp, "
"sender_name, format, epoch, device, nonce, sig")
def _row(r) -> StoredMessage:
return StoredMessage(
id=r[0], sender_id=r[1], iteration=r[2], payload=r[3],
- timestamp=r[4], thread_id=r[5], sender_name=r[6] or "",
- format=r[7] or FORMAT_PLAIN, epoch=r[8] or 0,
- device=r[9], nonce=r[10], sig=r[11],
+ timestamp=r[4], sender_name=r[5] or "",
+ format=r[6] or FORMAT_PLAIN, epoch=r[7] or 0,
+ device=r[8], nonce=r[9], sig=r[10],
)
@@ -132,6 +134,7 @@ class ChatStore:
# nothing to show for it.
pass
await self._db.execute(_REPLAY_INDEX)
+ await self._db.execute("DROP INDEX IF EXISTS idx_messages_thread")
await self._db.commit()
async def close(self) -> None:
@@ -151,7 +154,6 @@ class ChatStore:
sender_id: str,
iteration: int,
payload: bytes,
- thread_id: str | None = None,
sender_name: str = "",
*,
format: int = FORMAT_PLAIN,
@@ -171,9 +173,9 @@ class ChatStore:
try:
cursor = await self._db.execute(
"INSERT INTO messages (sender_id, iteration, payload, timestamp, "
- " thread_id, sender_name, format, epoch, device, nonce, sig) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
- (sender_id, iteration, payload, ts, thread_id, sender_name,
+ " sender_name, format, epoch, device, nonce, sig) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ (sender_id, iteration, payload, ts, sender_name,
format, epoch, device, nonce, sig),
)
except aiosqlite.IntegrityError as e:
@@ -234,16 +236,6 @@ class ChatStore:
"SELECT 1 FROM messages WHERE id < ? LIMIT 1", (message_id,))
return await cursor.fetchone() is not None
- async def get_thread(self, thread_id: str, limit: int = 100) -> list[StoredMessage]:
- """Get messages in a thread."""
- cursor = await self._db.execute(
- f"SELECT {_COLUMNS} "
- "FROM messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?",
- (thread_id, limit),
- )
- rows = await cursor.fetchall()
- return [_row(r) for r in rows]
-
@property
def db_path(self) -> Path:
"""Where this store lives — the re-encryption command backs it up."""
diff --git a/packages/meshbay-node/src/meshbay_node/ops/chat.py b/packages/meshbay-node/src/meshbay_node/ops/chat.py
index 9fc7b94..7caf85c 100644
--- a/packages/meshbay-node/src/meshbay_node/ops/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/ops/chat.py
@@ -214,7 +214,6 @@ async def encrypt_chat_history(state: dict, group_id: str) -> dict:
if isinstance(row.payload, bytes) else str(row.payload))
env = seal(key, group_id, epoch, device_b64, device_raw, sk_node, {
"text": text,
- "thread_id": row.thread_id,
"sender_name": row.sender_name,
"sent_at": int(row.timestamp),
# The node sealed this after the fact; it did not witness it being
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
index ed2c43a..5bc9dbb 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/chat.py
@@ -75,7 +75,7 @@ _LINK_PREVIEW_RATE_NODE = 60
# C5a; this is the same question asked of the path nobody had asked it of.
#
# 64 KB of ciphertext is about sixty thousand characters. The sealed payload is
-# the text, a thread id, a display name and a timestamp: an attachment is a file
+# the text, a display name and a timestamp: an attachment is a file
# on a root and travels as a reference (§4.5), so nothing legitimate comes close.
MAX_CHAT_CIPHERTEXT = 64 * 1024
# Per account per group, not per connection: a second tab does not make a person
@@ -330,18 +330,14 @@ class ChatMixin:
# anyone on the node.
gctx = self._group_ctx()
chat_store = gctx.get("chat_store")
- # The two fields that travel in clear beside the ciphertext (the sealed
- # envelope carries its own). Stored and relayed to every member, so they
- # are what they claim to be and no larger: a name as long as a username,
- # a thread id as long as a message id. Anything else is dropped.
+ # The one field that travels in clear beside the ciphertext (the sealed
+ # envelope carries its own). Stored and relayed to every member, so it is
+ # what it claims to be and no larger: a name as long as a username.
+ # Anything else is dropped. A `thread_id` from an older client is
+ # ignored: chat has no threads.
sender_name = msg.get("sender_name", "")
if not isinstance(sender_name, str) or len(sender_name) > 64:
sender_name = ""
- thread_id = msg.get("thread_id")
- id_like = (isinstance(thread_id, int) and not isinstance(thread_id, bool)
- or isinstance(thread_id, str) and len(thread_id) <= 64)
- if thread_id is not None and not id_like:
- thread_id = None
# Two shapes, and keeping them apart is what makes this deployable.
#
@@ -387,7 +383,7 @@ class ChatMixin:
self._spawn(self._store_chat_message(
chat_store,
iteration=msg.get("iteration", 0), payload=raw,
- thread_id=thread_id, sender_name=sender_name,
+ sender_name=sender_name,
format=fmt, epoch=epoch, device=device, nonce=nonce, sig=sig,
))
@@ -398,7 +394,6 @@ class ChatMixin:
"sender_id": self._user_id,
"sender_name": sender_name,
"payload": payload,
- "thread_id": thread_id,
"timestamp": time.time(),
"format": fmt,
"epoch": epoch,
@@ -613,7 +608,6 @@ class ChatMixin:
"sender_id": m.sender_id,
"sender_name": m.sender_name or names.get(m.sender_id, ""),
"timestamp": m.timestamp,
- "thread_id": m.thread_id,
"format": m.format,
"epoch": m.epoch,
"device": m.device,
diff --git a/packages/meshbay-node/tests/test_chat_is_bounded.py b/packages/meshbay-node/tests/test_chat_is_bounded.py
index c48f26d..f98fd81 100644
--- a/packages/meshbay-node/tests/test_chat_is_bounded.py
+++ b/packages/meshbay-node/tests/test_chat_is_bounded.py
@@ -210,10 +210,10 @@ async def test_one_member_at_their_limit_has_not_spent_anyone_elses(ctx, store):
async def test_the_clear_fields_are_what_they_claim_and_no_larger(ctx, store):
"""
- `sender_name` and `thread_id` travel in clear beside the sealed envelope,
- which carries its own. They are stored on the operator's disk and relayed
- to every member, so a megabyte of name or a list for a thread id is dropped,
- not kept.
+ `sender_name` travels in clear beside the sealed envelope, which carries its
+ own. It is stored on the operator's disk and relayed to every member, so a
+ megabyte of name is dropped, not kept — and a `thread_id`, which an older
+ client still sends, is neither.
"""
alice = _session(ctx, store, user="alice", conn="c1")
bob = _session(ctx, store, user="bob", conn="c2")
@@ -225,17 +225,19 @@ async def test_the_clear_fields_are_what_they_claim_and_no_larger(ctx, store):
stored = (await store.get_recent(limit=1))[0]
assert stored.sender_name in ("", None)
- assert stored.thread_id is None
relayed = [m for m in bob.sent if m.get("type") == "chat_msg"]
- assert relayed and relayed[-1]["sender_name"] == "" and relayed[-1]["thread_id"] is None
+ assert relayed and relayed[-1]["sender_name"] == "" and "thread_id" not in relayed[-1]
-async def test_ordinary_clear_fields_pass_unchanged(ctx, store):
+async def test_an_older_clients_message_is_taken_its_thread_id_ignored(ctx, store):
alice = _session(ctx, store, user="alice", conn="c1")
+ bob = _session(ctx, store, user="bob", conn="c2")
msg = _message(alice, size=64)
msg["sender_name"] = "Alice"
msg["thread_id"] = "42"
alice._do_chat_message(msg)
await _drain(ctx)
stored = (await store.get_recent(limit=1))[0]
- assert (stored.sender_name, stored.thread_id) == ("Alice", "42")
+ assert stored.sender_name == "Alice"
+ relayed = [m for m in bob.sent if m.get("type") == "chat_msg"]
+ assert relayed and "thread_id" not in relayed[-1]
diff --git a/packages/meshbay-node/tests/test_chat_store.py b/packages/meshbay-node/tests/test_chat_store.py
index 2533921..c4dff6d 100644
--- a/packages/meshbay-node/tests/test_chat_store.py
+++ b/packages/meshbay-node/tests/test_chat_store.py
@@ -28,7 +28,6 @@ async def test_save_and_retrieve(store):
assert msgs[0].sender_id == "alice"
assert msgs[0].iteration == 0
assert msgs[0].payload == b"hello"
- assert msgs[0].thread_id is None
@pytest.mark.asyncio
@@ -52,15 +51,32 @@ async def test_get_messages_since(store):
@pytest.mark.asyncio
-async def test_thread_messages(store):
- await store.save_message("alice", 0, b"root", thread_id="t1")
- await store.save_message("bob", 1, b"reply", thread_id="t1")
- await store.save_message("carol", 2, b"other")
-
- thread = await store.get_thread("t1")
- assert len(thread) == 2
- assert thread[0].sender_id == "alice"
- assert thread[1].sender_id == "bob"
+async def test_the_thread_column_stays_for_an_older_node_and_its_index_goes(tmp_path):
+ """Chat has no threads; a node downgraded to a version that still names
+ `thread_id` in every query must keep reading its rows."""
+ import sqlite3
+ path = tmp_path / "chat.db"
+ with sqlite3.connect(path) as c: # as an older node left it
+ c.executescript("""
+ CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sender_id TEXT NOT NULL, iteration INTEGER NOT NULL,
+ payload BLOB NOT NULL, timestamp REAL NOT NULL,
+ thread_id TEXT DEFAULT NULL, sender_name TEXT DEFAULT '');
+ CREATE INDEX idx_messages_thread ON messages(thread_id);
+ INSERT INTO messages (sender_id, iteration, payload, timestamp, thread_id)
+ VALUES ('alice', 0, X'6869', 1.0, 't1');""")
+ s = ChatStore(path)
+ await s.open()
+ await s.save_message("bob", 1, b"now")
+ assert [m.payload for m in await s.get_recent()] == [b"hi", b"now"]
+ await s.close()
+ with sqlite3.connect(path) as c:
+ assert "thread_id" in [r[1] for r in c.execute("PRAGMA table_info(messages)")]
+ assert not c.execute("SELECT 1 FROM sqlite_master WHERE name = "
+ "'idx_messages_thread'").fetchone()
+ # What an older node selects still answers.
+ assert c.execute("SELECT thread_id FROM messages ORDER BY id").fetchall() == [
+ ("t1",), (None,)]
@pytest.mark.asyncio