Skip to content

vector-core: kind-1111 threaded replies have a builder but no public send path — bots can react to a threaded reply, but cannot create one #77

Description

@ngmisl

Summary

vector_core::community::v2::chat::build_comment_rumor builds a correct NIP-22 / CORD-03 §3
kind-1111 threaded reply, including the parent_root inheritance that keeps the root stable at
any depth. It has no production call site — only tests — and no public function on the send
surface reaches it. So a third-party client or bot built on the published vector_sdk (0.5.0) /
vector-core (0.4.0) can react to, edit and delete a kind-1111 reply, but cannot send one.

Everything below is from master @ fcd47a27. This is a missing-API report, not a bug report:
the hard part is already written and correct, it just is not reachable from outside the crate.

Who this is coming from

We run a read-only assistant bot in a Concord/Armada community (vector_sdk 0.5.0). It answers
questions in a channel. We went looking for how to make its answers thread properly and ended up
reading the client source, which is how we found the gap. Notes on rendering are included below
because they were needed to reach the conclusion, and because they might be useful to whoever
picks up the thread view.

Reproduction

A v1 community (COMMUNITY_MESSAGE, kind 3300). Owner opens a topic, bot answers, owner asks a
follow-up on the bot's answer, bot answers again. Four events, read from the local
vector.db events table (ids truncated to 8, tags verbatim):

inner id author tags
4bb21474 owner [["ms","53"]]
128f93d6 bot [["ms","600"],["e","4bb21474…","","reply"]]
f9c28dd5 owner [["ms","979"],["e","128f93d6…","","reply"]]
2b2f59f4 bot [["ms","73"],["e","f9c28dd5…","","reply"]]

Every event carries exactly one e tag marked reply, pointing at its immediate parent, and no
root marker. That is what build_inner_full produces, and — importantly — it is also exactly
what the Vector client itself sends
in the same situation (see below), so nothing here is
malformed or foreign-client-specific.

The result is a four-message flat chain that no client can group, because the root is not
recoverable from any single event: you can only rebuild the topic by walking the chain, and the
walk breaks the moment one link is outside your window.

What the source says (the part that made us stop guessing)

1. What Vector sends for a reply, at any depth.
strCurrentReplyReference is the id of the row the user clicked reply on (src/main.js:6444,
used at src/main.js:12091). It goes to the send_community_message command
(src-tauri/src/commands/community.rs:931-939), which passes it straight through as the single
reference argument:

  • v1: envelope::build_inner_full(..., reply.as_deref(), ...)
    (src-tauri/src/commands/community.rs:1116-1126) → one
    ["e", <parent>, "", "reply"] tag, no root marker
    (crates/vector-core/src/community/envelope.rs:217-221).
  • v2: chat::build_message_rumor(..., reply_ref, ...)
    (src-tauri/src/commands/community.rs:1011-1013) → a kind 9 with a NIP-C7 q quote tag
    naming the immediate parent.

Either way it is the immediate parent only, at any depth. Vector's own reply is therefore the
same flat shape our bot produces — no divergence to fix on our side.

2. Vector never sends kind-1111.
build_comment_rumor (crates/vector-core/src/community/v2/chat.rs:153) is called from
crates/vector-core/src/community/v2/chat.rs:715 and :782 (unit tests),
crates/vector-core/src/community/v2/service.rs:7666 (an integration test), and
crates/vector-core/src/community/v2/inbound.rs:1045 — a fixture whose own comment says it is
building "the shape Armada sends" (:1040). No production path. send_chat_message /
send_chat_message_at (v2/service.rs:254, :271) always call build_message_rumor.

3. The read side handles 1111 fully, and says the thread view is still to come.
parse_chat_rumor accepts kind::COMMENT and lifts the lowercase e into the same reply_to
slot a kind-9 q uses (v2/chat.rs:479-497), with the comment:

A threaded reply (NIP-22, CORD-03 §3). Vector's timeline renders it INLINE: the immediate
parent (the lowercase e) becomes the reply context, exactly like a kind-9 quote — never
dropped. The uppercase root tags stay on the rumor for a future thread view.

and, on reactions to a received 1111 (crates/vector-core/src/lib.rs:1902-1905):

Stored rows don't keep wire-kind fidelity yet, so a reaction to a received kind-1111 thread
reply claims 9 — Armada's fold ignores reaction k, and exact threading lands with the
thread-aware GUI.

So interop is deliberate and already in place: Vector reads Armada's threads today and holds
the root tags for later. Only the send direction is missing.

4. Frontend, for completeness. The timeline is flat and chronological — renderMessage
appends one row per message (src/js/render/chat/message-row.js; append sites
src/main.js:6844, :6870, :6996), grouping is purely temporal
(shouldCollapseStreak, src/js/render/chat/message-streak.js:40-63), and a reply's only
affordance is a one-line quote of its immediate parent resolved by exact id
(_dmsgBuildReplyContext, message-row.js:452-460, inserted at :181-190). Nothing walks a
chain or reads an uppercase E. Consistent with the comments in (3): there is no thread view
yet, so a sender cannot make the current UI group anything — which is exactly why we are asking
for the send API rather than proposing a rendering change.

The gap

v2/service.rs exposes send_message (:242), send_chat_message (:254),
send_chat_message_at (:271), send_reaction (:291), send_edit (:311),
send_delete (:327) and moderation_delete (:349). Three of those already accept
kind::COMMENT as a targetsend_reaction's doc explicitly says "kind::COMMENT for a
threaded reply" (:286-289) — so the crate can address a threaded reply it did not send. The two
helpers needed to send one, chat_send_context (:415) and publish_chat (:439), are private.

vector_sdk 0.5.0 inherits that: Channel::reply(replied_to, text) (src/lib.rs:833) and
IncomingMessage::reply(text) (src/lib.rs:935) both funnel into
VectorCore::send_community_message(channel_id, content, replied_to)
(crates/vector-core/src/lib.rs:1631), which has one reply slot and no notion of a root.

Net effect for a bot author: a kind-1111 threaded reply is unreachable, in every combination of
public calls, on both the SDK and vector-core.

Concrete request

Anything that reaches build_comment_rumor. In rough order of preference:

  1. v2::service::send_comment(...) — mirror send_chat_message_at, forwarding
    build_comment_rumor's arguments:

    pub async fn send_comment<T: Transport + ?Sized>(
        transport: &T,
        community: &CommunityV2,
        channel_id: &ChannelId,
        content: &str,
        parent_id_hex: &str,
        parent_kind: u16,
        parent_author_hex: &str,
        parent_root: Option<(&str, u16, &str)>,
        emoji: &[(&str, &str)],
    ) -> Result<String, String>;

    plus a _at variant, for parity with the precomputed-rumor-id contract described at
    v2/service.rs:266-269.

  2. An SDK wrapper — e.g. Channel::reply_threaded(&self, parent: &ReplyTarget, text: &str),
    where ReplyTarget carries (id, kind, author, root). Ideally IncomingMessage would expose
    the root of the message it arrived on, since the parse side already has the uppercase tags on
    the rumor (v2/chat.rs:483) — that is the one piece a bot cannot reconstruct locally.

  3. Minimum viable: make chat_send_context and publish_chat pub (or
    pub(crate)pub on a #[doc(hidden)] escape hatch). Less pleasant, but it unblocks
    external clients without new API surface to commit to.

A v1 equivalent is not requested — v1 has no comment kind, and if v1 threading ever matters, a
root slot on build_inner_full alongside the existing reference would be the natural shape.

Why a bot author cannot work around this

  • Building the rumor by hand means reimplementing channel_binding_tags, the group-key
    derivation, the seal, and the epoch/session handling in publish_chat — i.e. reimplementing
    the encryption layer against private helpers, then keeping it in step with every rekey change.
    For a client whose entire value is that Vector's crypto is doing the work, that is the one
    thing it must not do.
  • send_chat_message's reply_to cannot express a root: it emits a q tag on a kind 9, and the
    parse side reads exactly one target from it (v2/chat.rs:463-474).
  • Passing a root id as the reply_to parent (which is what we tried first) is strictly worse: it
    makes every answer quote the opening question instead of the one being answered, and it points
    jump-to-message and the reply-to-me ping (message-row.js:139-144) at the wrong message. We
    have reverted it and now send the same flat shape Vector does, which is the best available
    behaviour until the send API exists.

Happy to test a branch against a live Concord community and report back, and happy to be told
this is already on the roadmap behind the thread-aware GUI — in that case, a note on
build_comment_rumor saying "no public sender yet, see #N" would have saved us the source dive.

Thanks for a genuinely pleasant codebase to read — the comments explaining why are the reason
this report could be specific.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions