fix(clients): p-tag the author a reply answers, and say it is not a mention - #5806
fix(clients): p-tag the author a reply answers, and say it is not a mention#5806cyberzero000 wants to merge 3 commits into
Conversation
Merged with main
|
Merged with main again — the p-tag fix moved into native catch-up
The native version computed Ported into
Deleted as superseded: Only the catch-up half went native — the live path still runs in JS — so |
A NIP-10 reply should `p`-tag the author it answers — `require_mention`
subscriptions add `#p` to the relay-side REQ filter
(`crates/buzz-acp/src/relay.rs`), so without that tag the relay never transmits
the event and an agent replied to in a thread hears nothing. But as a bare tag,
`["p", <pubkey>]` added because a reply addresses you is byte-identical to one
added because someone typed `@you`, and the two must behave differently: a
mention pierces a channel or thread mute and raises dock-badge priority, being
replied to does not.
Without a marker a receiver had to fetch the parent message and check who wrote
it — a relay round trip to recover something the sender knew for free, plus the
caching, chunking, retry and fail-open handling that round trip needs.
Replies now mark each `p` tag with the role it plays, in the fourth position,
the way `e` tags already carry `root` and `reply`:
["p", <typed pubkey>, "", "mention"] someone typed as @name
["p", <parent author>, "", "reply"] the author being answered
["p", <dm participant>] addressed by the channel
The third shape is what a DM needs: a DM tags every other participant whether or
not anyone typed their names, so neither marker is true of those tags, and
claiming `mention` would let a DM thread reply pierce a mute and take a slot in
the mention feed ahead of a real `@you`. They stay bare, which under the read
rule below means "ask the parent" — the answer they already got.
Four properties make this safe to land without coordinating clients:
- Read one-way. A marker that is present is authoritative. An absent marker
means "ask the parent", never "this is a mention". Senders that predate the
markers keep working exactly as before, and there is no flag day.
- `#p` delivery is untouched. Relay tag filters compare only a tag's second
element (`crates/buzz-core/src/filter.rs`), so a marker cannot affect which
agents receive the event.
- Top-level messages stay bare. Without a parent there is nothing to
disambiguate, and a `p` tag there can only be a mention.
- The undecidable case is settled. When you are both the author being answered
and typed in the body, one tag cannot be marked and unmarked at once. The
sender emits the mention marker and mention wins — the answer that preserves
the stronger signal, and one no amount of parent-fetching could reach.
Emitted by the Tauri backend, and by `buzz-sdk` for the CLI and the ACP harness.
Typed mentions and channel-addressed recipients travel as a named pair
(`Recipients { typed, addressed }`) rather than two adjacent same-typed lists —
conflating them is the exact mistake the markers exist to prevent.
Read by the native catch-up scan and the home feed. `p_tag_role` is the shared
vocabulary; `unread_notify` holds the gates, ported from `shouldNotify.ts`
function for function, and `unread_parent_authors` resolves a parent only when
no marker answers the question. A marked reply costs no round trip.
Two contracts became load-bearing. `ThreadRef.parent_author` must be the author
of `parent_event_id`, not of the thread root, because receivers read the
addressing marker as "this answers a message you wrote". And the self-null that
suppresses the addressing tag on a self-reply is judged against the *signing*
key: a managed agent posts under its own key, so comparing against the desktop
owner's would strip the tag off a reply to the owner.
The backend is the source of the addressing tag rather than the frontend, which
closes a gap in the delivery fix itself. `resolve_thread_ref` already fetches the
parent event on its way to the thread root, so `parent.pubkey` costs no extra
query and is strictly more reliable than the frontend cache the tag was
previously read from — that cache silently missed for any channel not opened in
the current session, which would have shipped the reply with no addressing tag
at all.
High-priority classification fails closed on an unresolved parent. The flag is
persisted and drops the channel's top-level items from the dock badge, so
guessing "high" after a relay flap would silently hide an approval request until
the channel is read.
Event ids are validated and case-normalized at the filter boundary. Any member
can publish an event whose `e` tag is not a 64-char hex id — the relay's NIP-10
resolver ignores such a tag rather than rejecting the event. Put into an `ids`
REQ filter the relay answers a bare NOTICE, and this client only resolves
`rate-limited:` notices, so the request hangs the full 25s history timeout. One
such event in a channel made every parent lookup there fail, leaving the channel
with no badge, no unread events and no thread activity for the session, and it
did not clear on restart.
`commands/feed.rs` is `get_feed` moved out of `commands/messages.rs`, which main
leaves at 988 lines against the 1000-line ratchet — adding the role check trips
the guard, and AGENTS.md says split rather than raise. `events/message_tags.rs`
and `events/identity_archive.rs` are the same story for `events.rs`, and
`unread_notify.rs` for `unread_catch_up.rs`.
`NOSTR.md`'s NIP-10 row and its `nak` reply example now describe the markers,
since that document is what third-party clients read and it previously taught
them to emit an unmarked reply.
Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
`_buildReplyTags` emitted `e` tags for the thread and `p` tags for explicit
mentions, but never a `p` tag for the author being replied to, so a reply to an
agent never reached it — `require_mention` subscriptions filter on `#p`
relay-side, and the agent cannot compensate for an event it never receives.
Adding that tag makes it indistinguishable from a typed `@mention`, so both
channel and forum providers now mark each `p` tag with its role, matching the
backend and `buzz-sdk`:
["p", <typed pubkey>, "", "mention"]
["p", <parent author>, "", "reply"]
["p", <dm participant>]
`message_recipients.dart` replaces `message_mention_pubkeys.dart`: it returns
the two groups separately rather than one merged list, because merging them is
the mistake the markers exist to prevent. Emission order — mentions, then bare,
then addressing — matches the Rust and TypeScript senders, so a reply's tags are
byte-identical whichever client sent it.
Mobile never had the eligibility half of this bug: it already resolves `@name`
against relay membership rather than the agent's self-declared `channel_ids`.
Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Teaching the senders to emit an addressing `p` tag means teaching every path that reads a `p` tag to tell it apart from a mention. There are seven of them — the live notify path, the dock badge, the home feed, the channel unread scan, the live channel updates, the community observer, and the app-shell notification effect. Miss one and that path treats every reply as a mention: it pierces mutes, takes a slot in the mention feed ahead of a real `@you`, and raises the dock badge. `shouldNotify.ts` reads the sender's marker when there is one and falls back to the parent's author when there is not, matching `unread_notify.rs` function for function. The two decide notification ownership independently from the same question, so a divergence makes them disagree and notify twice, or not at all. Grouped by what was wrong: - Double-notify. A reply could be counted by both the backend feed poll and the frontend live path. Exactly one owner notifies per event now. - Mutes. A muted channel or thread leaked replies; a muted DM inverted; a real `@mention` inside a muted thread was reported as notifying but did not. - Priority. High-priority classification failed open on an unresolved parent, so an addressing tag could raise the dock badge as if it were a mention. It fails closed, and the addressing tag is out of both priority and badge math. - Dock badge. A DM thread reply counted twice. - Deferral. Handing an unresolvable reply to the live path lost the notification instead of deferring it: `collectHomeAlertItems` returns the whole mention list unfiltered and the notification effect added every item it saw — including ones just declined as `replyToSelf` — to the persisted seen set, so the declining poll consumed the slot and the next poll dropped the item as already-seen. A genuine typed `@mention` inside a thread was lost for good, across restarts, because its parent belongs to a third party and so looks unresolved. - Robustness. Event ids are validated and case-normalized at the filter boundary, matching the Rust side. A single channel's failed parent lookup no longer aborts the whole community poll and drops the community to `state: "error"`, which would clear its dot and badge outright — undercounting one channel for 30 seconds is the smaller wrong answer. `messageRecipients.ts` replaces `messageMentionPubkeys.ts` and returns typed mentions and channel-addressed recipients as a named pair rather than one merged list. `replyContextEvents.ts` resolves a reply's parent from every cache that can hold it — the channel timeline is not the only one, the thread panel keeps its replies under a separate key and the Inbox can answer a message in a channel the user never opened this session, and looking only at the channel cache silently missed both. `unreadReadMarker.ts` is a split of `useUnreadChannels.ts`, which crossed the 1000-line ratchet. Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
c2b7809 to
0dc8641
Compare
A NIP-10 reply should
p-tag the author it answers. Buzz's clients never did,and
require_mentionsubscriptions add#pto the relay-side REQ filter(
crates/buzz-acp/src/relay.rs), so the relay never transmitted the event and anagent replied to in a thread heard nothing. The agent cannot compensate — it does
not know the message exists.
Observed on a live relay: a reply to an agent's message stored with tags
[["h","<channel>"],["e","<parent>","","reply"]]and no answer, while@mentions in the same channel minutes earlier were answered in 23 seconds. Thecomment above
buildReplyTagsalready stated the intent — p-tags are there "somention-filtered subscriptions (e.g. ACP agent harness) receive the reply event."
Adding that tag exposes a problem the protocol has, not this deployment, and
that problem is most of this PR.
The addressing tag is indistinguishable from a mention
As a bare tag,
["p", <pubkey>]added because a reply addresses you isbyte-identical to one added because someone typed
@you. The two must behavedifferently: a mention pierces a channel or thread mute and raises dock-badge
priority, being replied to does not.
Nothing distinguished them, so a receiver had to fetch the parent message and
check who wrote it — a relay round trip to recover something the sender knew for
free, plus the caching, chunking, retry and fail-open handling that round trip
needs.
Replies now mark each
ptag with the role it plays, in the fourth position,the way
etags already carryrootandreply:The third shape is the one a DM needs. A DM tags every other participant whether
or not anyone typed their names, so neither marker is true of those tags — and
claiming
mentionwould let a DM thread reply pierce a mute and take a slot inthe mention feed ahead of a real
@you. They stay bare, which under the readrule below means "ask the parent" — exactly the answer they already got. The
ordinary DM reply is unchanged in shape, because the counterpart is also the
parent's author and so gets one tag, marked
reply.Four properties make this safe to land without coordinating clients:
means "ask the parent", never "this is a mention". Senders that predate the
markers keep working exactly as before, and there is no flag day.
#pdelivery is untouched. Relay tag filters compare only a tag's secondelement (
crates/buzz-core/src/filter.rs,t.content()), so a marker cannotaffect which agents receive the event.
disambiguate, and a
ptag there can only be a mention.answered and typed in the body, one tag cannot be marked and unmarked at once.
The sender emits the mention marker and mention wins — the answer that
preserves the stronger signal, and one no amount of parent-fetching could have
reached.
Emitted by all four senders: the Tauri backend,
buzz-sdkfor the CLI and theACP harness, and mobile's channel and forum providers. Typed mentions and
channel-addressed recipients travel as a named pair (
Recipients { typed, addressed }in Rust,{ mentions, addressed }in TypeScript) rather than twoadjacent same-typed lists — conflating them is the exact mistake the markers
exist to prevent. Emission order is mentions, then bare, then addressing, in all
three languages, so a reply's tags are byte-identical whichever client sent it.
Two contracts became load-bearing.
ThreadRef.parent_authormust be the authorof
parent_event_idand not of the thread root, because receivers read theaddressing marker as "this answers a message you wrote". And the self-null that
suppresses the addressing tag on a self-reply is judged against the signing
key: a managed agent posts under its own key, so comparing against the desktop
owner's would strip the tag off a reply to the owner.
The backend is the source of the addressing tag rather than the frontend, which
also closes a gap in the delivery fix itself.
resolve_thread_refalreadyfetches the parent event on its way to the thread root, so
parent.pubkeycostsno extra query and is strictly more reliable than the frontend cache the tag was
previously read from — that cache silently missed for any channel not opened in
the current session, which would have shipped the reply with no addressing tag
at all and reproduced the original bug.
NOSTR.md's NIP-10 row and itsnakreply example now describe the markers,since that document is what third-party clients read and it previously taught
them to emit an unmarked reply.
Notification correctness
Teaching the senders to emit an addressing
ptag means teaching every paththat reads a
ptag to tell it apart from a mention. There are seven — thelive notify path, the dock badge, the home feed, the channel unread scan, the
live channel updates, the community observer, and the app-shell notification
effect. Miss one and that path treats every reply as a mention. That fan-out is
where most of this branch went.
Grouped by what was wrong:
the frontend live path. Exactly one owner notifies per event now.
real
@mentioninside a muted thread was reported as notifying but did not.parent, so an addressing tag could raise the dock badge as if it were a
mention. It fails closed, and the addressing tag is out of both priority and
badge math.
notification instead of deferring it.
collectHomeAlertItemsreturns thewhole mention list unfiltered and the notification effect added every item
it saw — including ones just declined as
replyToSelf— to the persisted seenset, so the declining poll consumed the slot and the next poll dropped the
item as already-seen. A genuine typed
@mentioninside a thread was lost forgood, across restarts, because its parent belongs to a third party and so
looks unresolved.
the oldest replies instead of the newest when trimming its window, stranded
participation claims on failure, and retried unscoped — re-sweeping every
channel rather than the failed one, indefinitely on a fixed 5s cadence behind
the same rate-limit gate as foreground channel history.
boundary. Any member can publish an event whose
etag is not a 64-char hexid — the relay's NIP-10 resolver ignores such a tag rather than rejecting the
event. Put into an
idsREQ filter the relay answers a bareNOTICE, andthis client only resolves
rate-limited:notices, so the request hangs thefull 25s history timeout. One such event in a channel made every parent lookup
there fail, leaving the channel with no badge, no unread events and no thread
activity for the whole session, and it did not clear on restart because the
event stays inside the read-marker window.
Since #6024 moved the unread scan into
unread_catch_up.rs, the gates live inunread_notify.rsbeside it, ported fromshouldNotify.tsfunction forfunction. The two decide notification ownership independently from the same
question, so a divergence makes them disagree and notify twice, or not at all.
unread_parent_authors.rsresolves a parent only when no marker answers thequestion, so a marked reply costs no round trip.
Why this isn't split further
Two of the three defects originally on this branch have been split out and stand
alone:
buzz channels set-add-policyerasing the agent profile.channel_idscache insteadof relay membership.
What remains is one causal knot and cuts badly:
ptag with nothingreading the marker turns every reply into a mention that pierces mutes and
raises the dock badge — worse than the bug being fixed.
others treating every reply as a mention.
rather than raised.
commands/feed.rsis the clearest case: main leavescommands/messages.rsat 988 lines against the 1000 limit, so adding thethree-line role check to
get_feedtrips the guard. That extraction is ~400lines of moved code for a three-line behaviour change, and no scoping decision
avoids it. Same story for
events/message_tags.rsandevents/identity_archive.rsout ofevents.rs,unread_notify.rsout ofunread_catch_up.rs, andunreadReadMarker.tsout ofuseUnreadChannels.ts.Roughly half the remaining diff is tests.
Testing
addressing tag keeping its slot under the mention cap, a typed parent author
tagged once as a mention, top-level messages staying bare, and a mention
piercing a mute on a thread you started.
buzz-sdk803,buzz-cli363,buzz-acp271, anddesktop-tauri-test2794 passed / 0 failed / 18 ignored. The earlierrevision of this PR could not run the Tauri suite locally (a
sherpa-onnx-c-apilink failure unrelated to this branch); it runs now, soresolve_parent_authorsand theunread_notifygates are covered by theirown tests rather than by extracted standalone programs.
tsc --noEmit,biome check,cargo clippy --workspace --all-targets -D warnings,cargo clippy(Tauri),cargo fmt --check,flutter analyze,dart format --set-exit-if-changed,check-px-textandcheck-file-sizesall clean.
resolve_parent_authors's REQ was also exercised against a running relaydirectly. The exact
{kinds, ids, limit}filter it builds is accepted andreturns the parent authors, and a full 200-id
PARENT_LOOKUP_CHUNKisaccepted in one request.
History
Rewritten from 36 commits into 3, by client surface. The original sequence was
17 review cycles in which several commits fixed regressions introduced two
commits earlier, which made reading it commit-by-commit actively misleading. The
tree is unchanged; only the history was re-cut. Rebased onto current
main.