7.4 KiB
Morphit chat threading model — CANONICAL, DO NOT DEVIATE
This document is the single source of truth for how Morphit threads chat conversations. It exists because this exact behavior was broken and re-broken across three releases (v1.4.7 → v1.4.9). Every one of the invariants below is protected by a tamper-tested regression guard (listed per invariant). If you are about to change anything in the chat send path, the chat handler, the conversations query, or the conversation-view routing — read this first, and do not weaken a guard to make a change "pass."
What the operator (Ken) asked for, verbatim
- Keep the inbox / starred / archived model. It is loved. Do not replace it.
- Some users just want to chat with each other, not bound to a specific order id — and that is a thread of its own with that user.
- Some users will click the "Message @username" button on an ordercard, and that will need its own thread.
- Got it.
The model in one sentence
A conversation thread is identified by the pair (peer, order_permlink),
where order_permlink is either a specific order or null (no order). Both
values are legitimate; null is a real thread, not a bug.
The invariants (each has a guard)
INV-1 — The inbox/starred/archived model is preserved
One card per thread. A thread can be starred or archived. The three tabs
(Inbox / Starred / Archived) stay. Folder state is keyed on (peer, order) per
card.
Guard: chat-inbox-threading-smoke, chat-ui-v148-smoke.
INV-2 — A no-order chat is a first-class thread of its own
When two people chat without citing an order (e.g. the profile page
"Message @user" button), order_permlink = null. That null thread is a
legitimate conversation of its own with that person. It MUST NOT be merged into
an order thread, and it MUST NOT be hidden or treated as an error.
Guard: conversations.ts groups a NULL permlink as its own group
(Postgres GROUP BY treats NULLs as equal) — pinned by
chat-thread-model-smoke; behaviorally exercised by
test/integration/conversations.test.ts ("the order-less thread").
INV-3 — An order chat is its own thread, separate from null and from other orders
When someone opens a chat from an ordercard, order_permlink = that order, and
that thread is distinct from the null thread and from every other order's thread.
Guard: chat-thread-model-smoke (grouping is by (peer, order_permlink)),
test/integration/conversations.test.ts.
INV-4 — The same two people can hold multiple threads at once
One null thread PLUS one thread per order they have discussed. Each is a separate
inbox card.
Guard: test/integration/conversations.test.ts (multiple cards, distinct
order_permlink per card).
INV-5 — BOTH parties always converge on the SAME thread
This is the invariant that was broken for three releases. For any single
conversation, both people must tag their messages with the SAME
order_permlink, or they land in different threads and cannot see each other.
There are TWO tag points, and they MUST agree:
-
Client tag (send path). Every outgoing message is tagged with
deps.orderPermlink, which must match the?order=in the URL the user is viewing.depsis captured ONCE inConversationView'sonMount(runtimeDeps(me, peer, …, orderPermlink ?? null)), so the conversation view MUST remount whenever the(peer, order)identity changes — otherwisedepsgoes stale and the send tags the wrong (or no) order. Enforced by:{#key \${peer}\u0000${orderPermlink ?? ''}`}around the lazily-loadedinapps/web/src/routes/[lang]/chat/[peer=account]/+page.svelte. _Guard:_chat-thread-remount-smoke` (tamper-tested). -
Server tag (stored on chain → indexed). The indexer's chat handler stores the message's
order_permlinkwhenever the tag names a real order owned by EITHER party (the validator'saccount IN (recipient, signer)). It MUST NOT gate storage onorderResponseBypass(which is true only when the recipient owns a live order) — doing so strips the ORDER OWNER's own replies of their tag, splitting them into a phantomnull"RE: -" thread the other party never sees.orderResponseBypassgoverns ONLY the stranger-fee gate and must stay that narrow. Enforced by:orderResponseBypass ? … : nullwas replaced withclaimedPermlink ?? nullinapps/indexer/src/indexer/handlers/chat.ts. Guard:chat-order-tag-storage-smoke(tamper-tested). -
Viewer filter (receive path). A conversation view shows only the messages whose
order_permlinkmatches the thread it represents (rec.order_permlink !== deps.orderPermlink → skip). This is correct — it is what keeps the order thread and the null thread separate — and it depends entirely ondeps.orderPermlinkbeing correct (see the client tag above). Guard:chat-inbox-threading-smoke(the order-filter line), plus the reconciliation guard inchat-fastpath-dedup-smoke. -
Every delivery path must carry the tag (cp470). The viewer filter above is only as good as the
order_permlinkon each message it sees. A message reaches the client by TWO paths — the REST history endpoint (/v1/chat/:a/:b) and the SSE stream (/v1/chat/:a/:b/stream, both the fast-path provisional and the durable push). BOTH serialize through code that MUST includeorder_permlink. cp470: the SSE serializerrowToWire(apps/indexer/src/api/chatStreamHelpers.ts) omitted it, so every live message shipped an implicitnulltag; order-thread messages were filtered out on the receive path and only surfaced ~one main-indexer lag later via the REST fallback poll — the "fast chat is broken in order threads" bug. General/order-less threads hid it (their tag is genuinely null). The DB query +ChatStreamRowalready carried the column; only the wire dropped it. Guard:chat-sse-order-permlink-smoke(functional — callsrowToWireand asserts the tag survives for a real permlink AND for the null order-less case).
The lesson, so this is never chased again
Chat threading has two tag points — the client's deps and the server's
stored tag — and they must agree. A bug in either one splits a conversation.
When any chat-threading symptom appears ("replies don't show," "a second card
appeared," "fastchat is slow"), check BOTH:
- Is the client's
deps.orderPermlinkcorrect at SEND time? (URL,{#key}remount,merge.enter depsOrder/send.outgoingin the?chatdebug=1trace.) - Is the server storing that tag for the sender's role? (the INSERT ternary in the chat handler — it must keep the tag for EITHER party.)
Guard inventory (all registered in scripts/run-smokes.sh, all tamper-tested)
| Invariant | Guard(s) |
|---|---|
| INV-1 | chat-inbox-threading-smoke, chat-ui-v148-smoke |
| INV-2, INV-3, INV-4 | chat-thread-model-smoke, test/integration/conversations.test.ts |
| INV-5 client tag | chat-thread-remount-smoke |
| INV-5 server tag | chat-order-tag-storage-smoke |
| INV-5 viewer filter | chat-inbox-threading-smoke, chat-fastpath-dedup-smoke |
| This document itself | chat-thread-model-smoke (asserts the doc exists and the model constant is documented) |
If you add a chat feature that touches threading, add its invariant here AND a guard, in the same change. Never remove an invariant without the operator's explicit sign-off.