Skip to content

fix: agent mentions and thread replies that never reached the agent - #5806

Draft
cyberzero000 wants to merge 30 commits into
block:mainfrom
cyberzero000:fix/agent-mention-delivery
Draft

fix: agent mentions and thread replies that never reached the agent#5806
cyberzero000 wants to merge 30 commits into
block:mainfrom
cyberzero000:fix/agent-mention-delivery

Conversation

@cyberzero000

@cyberzero000 cyberzero000 commented Aug 13, 2026

Copy link
Copy Markdown

Three related defects that each end with a message an agent never receives. Found while debugging a self-hosted deployment where an agent answered @mentions in one channel and was silent in another, on the same relay, at the same time.

Fixing the first one exposed a fourth problem — one the protocol has, not this deployment — so the branch is longer than the three defects suggest. Sections 1–3 are the original bugs, section 4 is the encoding they forced, and section 5 is the notification correctness that came with it.

1. Thread replies never reach an agent

buildReplyTags (desktop) and _buildReplyTags (mobile) emit e tags for the thread and p tags for explicit mentions, but never a p tag for the author being replied to. NIP-10 says a reply should carry one.

That omission is not cosmetic here. require_mention subscriptions add #p to the relay-side REQ filter (crates/buzz-acp/src/relay.rs:3213), so the relay never transmits the event. The agent cannot compensate — it does not know the message exists. Replying to an agent in a thread silently reached nobody.

The comment above buildReplyTags already stated the intent: p-tags are there "so mention-filtered subscriptions (e.g. ACP agent harness) receive the reply event."

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.

2. Agent mention eligibility reads a cache nothing invalidates

relayAgentCanRespondInChannel gates on agent.channelIds.includes(channelId) — the channel_ids array from the agent's kind:10100 profile. Nothing keeps that array in sync with membership: buzz-acp only reads channels, and the relay never authors a 10100.

So an agent invited to a new channel subscribes immediately and answers anything p-tagged, but never appears in that channel's @mention picker, and typed text is not consulted (messageMentionPubkeys returns only explicit mentions outside DMs). The agent is present, listening, and unmentionable. Mobile never had the bug because it resolves @name against relay membership (send_message_provider.dart).

Fixed on both sides, either of which is sufficient alone:

  • Desktop accepts relay membership in place of channel_ids. Membership is maintained by the relay and cannot drift. respond_to and respondToAllowlist are still enforced, so this widens discovery, not authority.
  • buzz-acp updates its profile when it observes a membership change it already handles (crates/buzz-acp/src/lib.rs:2866). The update is a queued delta, not a rewrite from this harness's subscription set: that set is narrowed by channels_override, by rule matching, and by any channel whose startup subscribe failed, so publishing it wholesale would delete every channel this process happens not to serve, and two harnesses sharing a pubkey would flap the field against each other. Read-modify-write, so fields it does not own survive; a missing profile is left missing, because creating one belongs to deploy tooling.

3. buzz channels set-add-policy erases the agent profile

cmd_set_add_policy published a kind:10100 whose content was only {"channel_add_policy": ...}. The kind is replaceable, and the relay projects just that field into a column (crates/buzz-relay/src/handlers/side_effects.rs:1169) — channel_ids, name, display_name and respond_to live solely in the event body clients read. One policy change wiped the rest of the profile, which by defect 2 removed the agent from every channel's picker.

Now reads the current profile, merges the field, republishes. A failed or absent lookup degrades to the previous single-field publish rather than blocking a policy change.

4. The addressing tag is indistinguishable from a mention — so replies now say which they are

Defect 1's fix has a consequence. As a bare tag, ["p", <pubkey>] added because a reply addresses you is byte-identical to ["p", <pubkey>] added because someone typed @you. The two must behave differently: a mention pierces a channel or thread mute and raises dock-badge priority, being replied to does not.

Nothing distinguishes them in the event, 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 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 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 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" — 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:

  • 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:75, t.content()), 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 have reached.

Emitted by all four senders: the Tauri backend, buzz-sdk for the CLI and the ACP harness, and mobile's channel and forum providers. messageRecipients on both desktop and mobile returns the two groups separately. Consumed by shouldNotify and the desktop feed poll, where a marked tag skips the parent lookup entirely. Typed mentions and channel-addressed recipients travel as a named pair (Recipients { typed, addressed } in Rust, { mentions, addressed } in TypeScript) rather than as two adjacent same-typed lists — conflating them is the exact mistake the markers exist to prevent.

Two contracts became load-bearing in the process and are worth knowing if you touch this code. ThreadRef.parent_author must be the author of parent_event_id and 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 now the source of the addressing tag rather than the frontend, which also closes a gap in defect 1's own fix. 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 and reproduced the original bug.

5. Notification correctness

Teaching the clients that a reply carries an addressing p tag means teaching every notification path to tell it apart from a mention. That is where most of the branch went. 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 now fails closed, and the addressing tag is out of both priority and badge math.
  • Dock badge. A DM thread reply counted twice.
  • Catch-up. The unread scan judged a mention against the wrong message, kept the oldest replies instead of the newest when trimming its window, stranded participation claims on failure, and retried unscoped.
  • Robustness. Event ids are validated and case-normalized at the filter boundary — the relay accepts a malformed e tag rather than rejecting it, and feeding that value back in an ids filter returned a bare NOTICE, hanging the client for 25s. One failing query no longer takes down the whole feed.

Testing

  • Reply p-tag on all four senders, self-reply adds no tag, mention dedup, the addressing tag keeping its slot under the mention cap, a typed parent author tagged once as a mention, top-level messages staying bare, membership standing in for a stale channelIds, membership not bypassing an allowlist, and a mention piercing a mute on a thread you started.
  • Full suites pass: 5196 desktop JS, 1550 mobile, plus buzz-sdk / buzz-cli / buzz-acp.
  • tsc --noEmit, biome check, cargo clippy --workspace --all-targets -D warnings, cargo fmt (workspace + Tauri), flutter analyze, check-px-text, check-file-sizes all clean.
  • desktop-tauri-test cannot run on this machine — sherpa-onnx-c-api is missing and the crate fails at link time, unrelated to this branch. desktop-tauri-clippy passes. The Rust tag builders were verified by extracting them into a standalone program.

Structural notes

Three files crossed the 1000-line ratchet as this landed. Per AGENTS.md the limit is not negotiable, so each was split rather than raised:

  • desktop/src-tauri/src/events.rs → tag builders moved to events/message_tags.rs, then the NIP-IA builders to events/identity_archive.rs (876).
  • desktop/src/features/channels/useUnreadChannels.tscatchUpMembership.ts + catchUpScan.ts.
  • desktop/src/features/messages/useMentions.ts → the two identical "normalise every pubkey in this list" blocks became normalizePubkeySet in shared/lib/pubkey.ts (995).

Merged with main

main moved 85 commits while this was in review, overlapping 23 of these files. Merged rather than rebased so the review history stays addressable. Five conflicts, all resolved keeping both sides — the notable ones:

  • resolve_thread_ref moved upstream into commands/messages/thread_ref.rs and gained a pinned keys snapshot for the read's NIP-98 auth. That snapshot and the self-reply check are deliberately separate arguments: a managed agent reads as the active identity but signs as itself, so one key authenticates the read and the other decides whether this reply is answering ourselves.
  • Main grew mobile/.../message_mention_pubkeys.dart, which folds DM recipients into the mention list — the conflation section 4 describes. The conflict could not be resolved without splitting it, so mobile now has message_recipients.dart returning {mentions, addressed} to match desktop, with tests for both DM reply shapes.
  • relayAgentCanRespondInChannel keeps main's ownerPubkey owner-only gating alongside this branch's relay-membership eligibility.

🤖 Generated with Claude Code

cyberzero000 added 4 commits August 13, 2026 14:49
A reply carried e-tags for the thread and p-tags for explicit mentions, but
never one for the author it was replying to. NIP-10 says it should, and here
that omission is not cosmetic: require_mention subscriptions add #p to the
relay-side REQ filter (buzz-acp relay.rs), so the relay never transmits the
reply at all. Answering an agent in a thread silently reached nobody, on both
desktop and mobile.

Desktop resolves the parent author from the message cache and passes it to
messageMentionPubkeys; mobile takes it from the thread head. The optimistic
local echo does the same so it matches what is published.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
An agent's kind:10100 carries a self-declared channel_ids array, and desktop's
@mention picker gated on it. Nothing ever republished it: buzz-acp only reads
channels, and the relay never authors a 10100. So an agent invited to a new
channel subscribed immediately and answered anything p-tagged, yet stayed
absent from that channel's picker until an operator republished the profile by
hand. Mobile never had the bug because it resolves @NAMEs against relay
membership.

Two changes, either of which fixes it alone:

- Desktop accepts relay membership in place of channel_ids. Membership is
  maintained by the relay and cannot drift; respond_to and respondToAllowlist
  are still enforced, so this widens discovery, not authority.
- buzz-acp republishes its profile when it observes the membership change it
  already handles, read-modify-write so fields it does not own survive. A
  missing profile is left missing — creating one belongs to deploy tooling.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
cmd_set_add_policy published a kind:10100 whose content was only
{"channel_add_policy": ...}. The kind is replaceable and the relay projects
just that one field into a column — channel_ids, name, display_name and
respond_to live solely in the event body clients read. One policy change
therefore wiped the rest of the profile and took the agent out of every
channel's @mention picker.

Read the current profile, merge the field, republish. A failed or absent
lookup degrades to the previous single-field publish rather than blocking a
policy change.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
useMentions.ts sat at exactly the 1000-line ratchet, so threading channel
membership into the agent eligibility check pushed it over. Both pubkey sets in
that file were the same "normalise every pubkey in this list" shape, now a
helper next to normalizePubkey where the next caller will find it.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
@cyberzero000
cyberzero000 requested a review from a team as a code owner August 13, 2026 21:56
@cyberzero000
cyberzero000 marked this pull request as draft August 13, 2026 22:36
cyberzero000 added 12 commits August 13, 2026 16:06
Follow-up to the review of this branch. Seven fixes:

- Send-boundary revalidation ignored channel membership, so it stripped
  exactly the agents the relaxed picker rule admits. Thread the member
  set through `useAgentMentionRevalidation`.
- The parent-author lookup read only the channel-messages cache, missing
  thread-panel parents. Add `getReplyContextEvents`, which spans the
  thread caches, and use it for root resolution too.
- The Inbox reply path bypasses the send mutation entirely; it now
  carries the parent author explicitly.
- Reply-derived p-tags pierced channel and thread mutes. `shouldNotify`
  takes the parent author so a reply answering us is not read as a
  mention.
- Forum replies never p-tagged the post author on desktop or mobile.
- The ACP republish rewrote `channel_ids` from this harness's
  subscription set, deleting channels it does not serve. Apply a delta
  instead, serialized so concurrent membership changes cannot collide on
  a same-second replaceable write.
- `set-add-policy` turned a failed profile lookup into an empty profile
  and silently erased the agent. It now fails loudly.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Second review pass. The first round of fixes had gaps of its own:

- The ACP profile republish serialized on a mutex but published through
  the socket publisher, which returns as soon as the command is queued,
  so concurrent deltas still read the pre-change profile. Replace the
  mutex + spawn with one ordered worker publishing via
  `RestClient::submit_event`, which awaits the relay — same-channel
  deltas now also apply in the order they were observed.
- `shouldNotifyForEvent` fell through to the participated/authored sets
  for a reply answering the user. Those are local and window-derived, so
  on a fresh install "someone replied to you" went unreported. Replies
  answering the user are re-admitted after the mute gates instead.
- The unread catch-up window starts strictly after the read marker, so
  it can never hold the parent of a catch-up reply. Fetch the missing
  parents, but only inside a mute, where the answer changes the outcome.
- `communityUnreadObserver` was not wired at all, and its mention count
  counted every reply.
- The thread-reply notification slot deferred to the mention slot
  whenever a p-tag matched, which is now every reply — so anyone with
  the mention slot off lost reply notifications entirely. Split
  `hasAuthoredMentionForEvent` out for that decision.
- The Inbox mention feed is a raw #p query, so replies pierced channel
  mutes there too. The Tauri feed command resolves reply parents and
  marks `reply_to_self`.
- `buzz messages send` and `send-diff` did not p-tag the author they
  answer — the agent-facing surface had the bug in reverse.
- A profile with empty content no longer blocks `set-add-policy`.

The reply-context lookup is no longer eager: it costs nothing for a
non-reply and scans the thread caches only on a channel-cache miss.

Five files hit the desktop size ratchet, so this also splits out
`feedTypes.ts`, `tauriFeedMapping.ts`, `inboxReplyRecipients.ts`,
`unreadReadMarker.ts`, and `commands/feed.rs`.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
…gory

Third review pass, mostly cleaning up round 2:

- Round 2 gave the thread_reply slot ownership of replies answering the
  user but left the same event in the home-feed mention section, so it
  toasted and sounded twice. The mention feed now drops `replyToSelf`
  items in `eligibleFeedNotificationItems`, which is the single place
  that decides feed eligibility.
- The Tauri feed emitted `category: "mentions"` while `FeedItemCategory`
  is `"mention"`, so every `category === "mention"` check downstream was
  dead — including round 2's mute gate and the "@Mention" toast title.
  The backend now emits the canonical singular form.
- The parent-author lookup read only the query caches, which exist only
  for channels opened this session, so on a cold channel it fell back to
  the raw p-tag and silenced the reply. It now falls back to the relay.
- `buzz messages send`/`send-diff` p-tagged the author even when that is
  the sender, putting your own replies in your own mention feed —
  `mention_tags` cannot drop self, it has no key. Same fix for forum
  replies on desktop, where the caller's self-check ran against a
  `currentPubkey` that is undefined until identity resolves.
- `communityUnreadObserver` resolved parents only inside a mute, but it
  already skips muted channels, and `unreadEvents` is empty once an
  earlier channel set `hasUnread` — so the mention count depended on
  channel iteration order. It now resolves every reply.
- The ACP worker treated `200 {"accepted": false}` as success and
  advanced its publish clock; it now reports the rejection. The
  same-second guard is also seeded from the profile's own `created_at`,
  so a concurrent deploy-tooling or CLI write cannot collide with it.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Fourth review pass. All four findings are gaps in the reply-vs-mention
ownership split the previous two rounds introduced.

- The mute decision resolved the parent's author from the query caches
  only, but those hold just the channel being viewed. For a muted channel
  the user has not opened this session the lookup always missed, so the
  reply's addressing p-tag re-read as a mention and pierced the mute. Only
  a mute can change the answer, so escalate to the relay exactly there and
  leave every other message on the synchronous path.
- A broadcast reply to one of the user's own messages notified zero times:
  the feed dropped it as `reply_to_self`, and the live path that was
  supposed to own it never sees broadcast replies, because `isThreadReply`
  excludes them by design. Stop marking them.
- The desktop and backend parent lookups used different kind lists, so a
  reply to a kind:40008 diff message resolved on one side and not the
  other and notified twice. Share one list, documented on both sides.
- `resolveReplyParentAuthor` collapsed a failed fetch and a genuinely
  missing parent to `null`, and the caller read `null` as "real mention" —
  so one relay hiccup handed the event back to a feed that had already
  dropped it. Report the two separately and keep the reply when the lookup
  merely failed.
- Every DM message p-tags both participants, so the new authored-mention
  test dropped DM replies from the community badge count — exactly the
  messages it exists for. In a DM the addressing tag is the point.
- Cap the ACP profile-publish wait. Its floor comes from the stored
  profile's `created_at`, which any peer can set, so a future timestamp
  parked the single worker and grew its queue for the whole clock skew.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Fifth review pass, all follow-on gaps from the previous round.

- `hasAuthoredMentionForEvent` had no broadcast carve-out, but
  `shouldNotifyForEvent` admits broadcast replies before it ever reads the
  parent and `feed.rs` gained one last round. An agent broadcast-replying
  to one of the user's messages therefore counted in the Home feed but not
  in the community rail's mention badge. Carve it out here too.
- Two parent lookups still filtered on their caller's unread kinds rather
  than the shared reply-parent set, so a reply answering a kind:40008 diff
  message left the parent unresolved — which reads as a mention and
  pierces the very mute the lookup exists to protect, and inflates the
  community badge.
- `needsResolvedParentAuthor` escalated broadcast replies to a relay round
  trip whose answer cannot change the outcome, stalling the unread bump
  behind it.
- The capped profile-publish wait turned "delayed" into a silent permanent
  drop: on timeout it signed an older `created_at`, the relay kept the
  stored event and could still report acceptance, and nothing requeued the
  delta. Stamp `created_at` past the floor instead — it wins the LWW
  compare with no waiting at all — and fail loudly when the skew is beyond
  what a relay would accept.
- The escalated notification path had no cancellation, so switching
  communities mid-flight still toasted for the community just left, and a
  throwing consumer callback became an unhandled rejection.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Sixth review pass.

- The profile updater rejected its own writes. Each delta costs two HTTP
  round trips, so a bulk invite drains the queue inside one second;
  stamping T, T+1 … meant the seventh exceeded the future-skew bound and
  returned an error the worker only logs, dropping that delta and every
  one behind it. Adding an agent to 20 channels left most of them missing
  from `channel_ids` — the exact staleness this feature exists to fix.
  Coalesce everything queued into a single publish instead: a burst now
  needs one second, not one per channel, and only a genuinely skewed peer
  can reach the bound.
- A muted DM had its priorities inverted: a new message notified, but a
  reply answering you did not. Every DM message p-tags both participants,
  so the addressing tag is how a DM is addressed at all — there is no
  mention to tell it apart from. `shouldNotifyForEvent` now knows when the
  channel is a DM, matching the exemption `communityUnreadObserver`
  already had for the badge count.
- The desktop reply handler awaits a parent lookup but had no mount guard,
  so switching communities mid-flight toasted for the community just left,
  click-through pointing at a channel id the new one does not have.
- `reply_to_self` was computed for kinds the live path cannot own — the
  mention query is wider than the unread-trigger set — so a bridged kind:1
  reply was dropped by the feed with nothing to pick it up.
- Forum replies passed `""` as the self pubkey before identity resolved,
  which seeds the dedupe set with the empty string and drops nobody; fall
  back to the profile query.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Seventh review pass.

- Coalescing only merged deltas already queued, so a *paced* stream of
  membership changes still climbed one second per publish and, past the
  skew bound, discarded a whole batch with nothing to requeue it. Sleep
  off the lead before publishing instead: each publish starts from `now`
  again, so the stamp cannot ratchet, and the cap becomes a rate limit
  rather than data loss. Only a genuinely skewed peer can still trip it.
- `isHighPriorityEventForUser` still read the raw `p` tag, so any reply to
  you marked its whole channel high-priority — and
  `shouldCountTowardHomeBadgeSubtotal` then drops top-level items in such
  a channel, hiding an approval request from the dock badge. It takes the
  parent's author now, threaded through `onChannelMessage`.
  `recordMentionedRoot` had the same conflation.
- `needsResolvedParentAuthor` escalated muted DMs to a relay round trip
  whose answer `shouldNotifyForEvent` then ignores.
- The Inbox reply path lacked the self-pubkey fallback forum replies got
  last round, so replying to your own Inbox message before identity
  resolved self-p-tagged it.
- `reply_parent_id` took the first `reply`-marked `e` tag where the TS
  `getThreadReference` takes the last, so a multi-marker event resolved a
  different parent on each side.
- The feed skipped parents already in the mention batch instead of
  answering from it, so a self-authored message that also p-tags you left
  its replies claimed by both paths.

Splits the catch-up constants and thread-relationship stores out of
useUnreadChannels.ts to stay under the desktop file-size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Eighth review pass.

Gating the parent lookup on a mute was right while `shouldNotifyForEvent`
was its only consumer — outside a mute the answer could not change the
outcome. Making `isHighPriorityEventForUser` depend on the parent broke
that assumption and the gate was never widened, so the previous commit
only landed its own fix for muted channels and for the channel currently
open. Everywhere else a reply answering the user still arrived with no
parent, still read as a mention, and still marked the channel
high-priority — which drops that channel's top-level items from the dock
badge, hiding approval requests. Both the live path and the startup
catch-up had it.

The condition is the `p` tag, not the mute: that is exactly when the
parent's author changes any answer. `collectReplyParentAuthors` hands its
predicate the event so callers can ask that question, and everything that
does not tag the user stays on the synchronous path.

Also carry a failed profile-delta batch into the next attempt instead of
dropping it. The queue is the only copy, so a peer's future timestamp or a
backwards NTP step left `channel_ids` wrong indefinitely behind one warn
line.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Ninth review pass.

- The escalated lookup collapsed `absent` and `unavailable` back to `null`,
  so a single transient fetch failure reintroduced exactly what the last
  round fixed: the addressing p-tag read as a mention, the channel was
  recorded high-priority, and — because that flag is persisted — its
  top-level items stayed out of the dock badge until the channel was read.
  `isHighPriorityEventForUser` now fails closed on a reply whose parent
  could not be resolved, where notification delivery still fails open.
  Missing a red dot after a relay flap is recoverable; silently hiding an
  approval request is not.
- Widening the escalation made the lookup O(replies) instead of
  O(distinct parents): thirty replies to one message issued thirty
  identical `#ids` REQs, each a fresh subscription behind the rate-limit
  gate that foreground history loads share. Answer a parent once and let
  concurrent callers await the same promise. Failures are not cached, so a
  flap does not become sticky, and the cache is cleared on community
  switch.
- The catch-up predicate did not exclude DMs, costing a startup round trip
  per DM channel for an answer both consumers there ignore.
- `buzz messages send --reply-to` appended the parent author after the
  mention list had already been truncated to MENTION_CAP, so a reply
  carrying 50 mentions failed to send outright. Make room for the
  addressing tag instead — without it the agent never receives the reply.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Tenth review pass. All three findings are about the *durability* of an
unresolved parent, not the branch logic.

- `highPriority` is persisted per event id and `recordObservedUnreadEvent`
  returns early for an id it already holds, so whichever way an unresolved
  parent is guessed, the guess is permanent for as long as the channel
  stays unread — across restarts. Failing closed traded "an approval
  request hidden from the dock badge" for "a genuine mention that never
  lights the badge, while its toast fires anyway". Neither is acceptable,
  so stop guessing on the first failure: retry the lookup twice with
  backoff. Retries are keyed per parent id, so concurrent replies to the
  same message share them.
- `collectReplyParentAuthors` swallowed its relay failure and returned a
  batch-only map, which defeated the retry both callers already have.
  `useUnreadChannels` keeps its `caughtUpChannelsRef` claim on success, so
  one timed-out parent query left every backlog reply in that channel
  permanently demoted; the community poller likewise marked a bad count as
  ready. It propagates now — both callers already treat a throw as "retry".
- Stop caching `absent`. A parent the relay does not return may simply be
  a kind outside the query set, and caching that poisoned every later
  reply to it — with the two consumers reading the null author in opposite
  directions.
- The desktop reply handler awaited a parent lookup for every thread
  reply, including ones that never tag the user, where the answer cannot
  change the outcome. Guard it the way the live path already does.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Eleventh review pass.

- Last round made `collectReplyParentAuthors` propagate so its callers
  could retry, but `useUnreadChannels` only *released* its claim — every
  other dependency of that effect is stable once the channel list settles,
  so nothing re-ran it. A timed-out parent query left the channel with no
  badge, no observed unread events and no thread activity for the rest of
  the session, which is worse than the wrong `highPriority` flag it
  replaced. A retry signal now re-arms the effect five seconds later.
- The community poll had the same shape one level up: a single channel's
  failure aborted the whole poll, discarding counts already accumulated
  and dropping the community to `state: "error"`, which clears its dot and
  badge outright. Scope the failure to its channel — undercounting one
  channel for 30 seconds is the smaller wrong answer.
- The Home/dock badge's channel-mute bypass keys off `category !==
  "mention"`, a condition that was dead while the backend emitted the
  plural spelling. Fixing that spelling activated it, so a reply reaching
  the mention feed only via its addressing p-tag began piercing channel
  mutes in the badge count while the sidebar and toasts stayed silent.
- Reserve room for the addressing tag on the desktop reply paths, matching
  what the CLI already does. Appending the parent author to a full mention
  list pushes the event past MENTION_CAP, and the builder rejects the
  whole event rather than trimming — so a reply with 50 mentions failed to
  send outright.

Splits the retry signal and the mentioned-root recorder out of
useUnreadChannels.ts to stay under the desktop file-size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Twelfth review pass.

- The retry I added last round re-swept every channel, not the failed one.
  The effect's cleanup released *all* claims, so each re-arm recomputed the
  full channel list and re-issued a 1000-limit REQ plus a parent lookup for
  every channel — indefinitely, on a fixed 5s cadence, behind the same
  rate-limit gate as foreground channel history. An offline app or one
  consistently failing channel would starve the UI the retry exists to
  serve. Cleanup now releases only claims whose results never landed, so a
  retry re-fetches the failures, and the delay backs off to a ceiling and
  resets after a clean run.
- The `accepted == false` guard on the profile republish never fired for
  the case its own comment described. A superseded replaceable write is
  rolled back but reported as `accepted: true` with a `duplicate:` message
  (`Db::replace_addressable_event` → `was_inserted = false`). So losing the
  LWW race was recorded as a successful publish and the worker cleared the
  deltas — its only copy. Check the message too.
- `buzz channels set-add-policy` is a read-modify-write on the same
  replaceable event the harness republishes, with no compare-and-set. Verify
  after publishing and redo the merge when someone else's copy is stored,
  so an operator running this during a channel invite no longer silently
  drops it. The window is narrowed, not closed — a peer publishing between
  our read and our write still loses its change, which is noted in place.

Splits the catch-up claim bookkeeping out of useUnreadChannels.ts to stay
under the desktop file-size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
cyberzero000 added 11 commits August 14, 2026 11:32
…laims

`set-add-policy` could report success for a write the relay discarded. Its
merge loop signed at `created_at = now`, and a same-second replaceable tie is
resolved by lowest event id, so a write racing the ACP harness lost roughly half
the time — and lost deterministically against a copy a skewed peer stamped
later. Retrying could not help, because each retry re-signed at the same second
with the same body and so produced the same losing event id. The loop also
short-circuited its own verification on the final attempt and read only
`accepted`, but a rolled-back replaceable write comes back as `accepted: true`
with `message: "duplicate:"`. All three attempts could lose and the operator
would still see an accepted write with the policy unchanged.

Stamp past the stored copy so the write wins by timestamp instead of by
coin flip, verify every attempt, route `duplicate:` through
`parse_write_response`, and exit 5 rather than 0 when no attempt is confirmed.

On the desktop side the catch-up merge had two exits that skipped the claim
release: the scope-drift guard and an uncaught throw. A channel that took either
stayed in both `claimed` and `inFlight`, where no later run would re-fetch it and
no later cleanup could release it — no badge for the rest of the session. Both
now release and arm the backoff.

Also chunk the parent-author lookup at 100 ids. Catch-up passes up to 1000 and
the community observer 150, and a truncated REQ is worse than a slow one here:
an unreturned parent reads as a mention, inflating one caller's count and
clearing the other's persisted `highPriority`. And let a reconnect duplicate
retry a lookup that never reached the relay, instead of discarding it as
already-seen and making one hiccup permanent for the session.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
…indow

A cold review of the whole branch — briefed only on the diff and the product
requirement, with none of the reasoning behind the earlier fixes — found a High
in a file no previous round had touched.

The Home/dock badge counted replies to your own messages. Those reach the
mention feed only via the NIP-10 addressing `p` tag, and `buildHomeBadgeFeedItems`
filtered thread replies out of `extraInboxItems` while passing `feed.feed.mentions`
through untouched. So a reply inflated the Inbox numeral that the same function
explicitly reserves against thread replies. Worse, this count has no
`mutedRootIds` input and the only mute check downstream is per-channel, so a
reply to your own message in a muted thread inside an unmuted channel drove the
macOS dock badge while the sidebar and toasts correctly stayed silent. Dropping
`replyToSelf` replies from the badge list removes both, and removes the whole
thread-mute class with them: a reply that is muted but still p-tags you is
exactly a reply to your own message. Typed mentions carry `replyToSelf: false`
and still pierce mutes, as they must.

Three more from the same review:

Replies now share the `#p` mention window with real mentions and are discarded
only after the query, so a thread answering you 50 times evicted every real
mention from a `limit: 50` window — an `@you` from that morning reached neither
the Inbox, the badge, nor a toast, and the live path cannot compensate because
it only sees what was published while connected. Over-fetch, then trim back to
the caller's cap preferring real mentions.

The backend parent lookup used `unwrap_or_default()`. Unlike its neighbours,
whose failure merely shortens the feed, this one's failure flips a
classification: an empty result is indistinguishable from "none of these parents
are mine", so one relay hiccup relabelled every reply a real mention — double
notifying in an unmuted channel and piercing the mute in a muted one, since the
frontend fails open on the documented assumption that this feed already dropped
what it resolved. Fail the poll and retry on the next tick instead.

Kind 40001 (legacy pre-migration stream messages) was missing from both
reply-parent lookup lists, so every reply to one resolved as "parent absent" —
which reads as a mention and pierces the mute the lookup exists to protect.

And the ACP profile updater retried a failed republish only when the next
membership change arrived. Churn is rare enough that "next change" can be never,
leaving the agent absent from every @mention picker for the life of the process.
It now waits on a delta or a backoff timer, whichever comes first.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Both Highs here are regressions from the two preceding commits. A second cold
review — briefed only on the diff and the requirement — caught them.

Un-marking an event so a reconnect could retry its parent lookup was wrong.
`deliver` fails open on a null author, so an `unavailable` lookup still fires the
toast, sound and dock bounce; releasing the delivery slot then let the relay's
~5s reconnect overlap fire all three a second time for one reply, and the
desktop-notification path has no dedupe of its own. The `.catch` was worse:
`resolveReplyParentAuthor` never rejects, so the only way in is `deliver` itself
throwing — after the notification has already gone out. Reverted; the slot stays
consumed because a notification really was delivered.

Making the reply-parent query fatal to `get_feed` was also wrong, and worse than
the mislabelling it replaced. `reply_parent_id` returns the `e` tag value
verbatim, and the relay *accepts* an event whose `e` value is not a 64-char hex
id — its thread-meta resolver ignores such a tag rather than rejecting the event.
So one junk event in the mention window makes the relay reject the filter as
malformed, and since the mention query carries no `since` that event stays in the
window: every 30s poll fails forever, leaving the Inbox empty, the dock badge at
zero and no mention toasts at all. Now the ids are validated before they reach a
filter, and a failed lookup hands the affected replies to the live path instead
of failing the poll. The live path runs its own lookup with retries, so an
unmuted channel decides correctly and a muted one stays muted, and a backlog
reply is picked up by the next poll that resolves its parent.

Two more from the same review. Excluding replies from the badge list dropped
them ahead of the `localUnreadFeedIds` override, so an explicit "Mark as unread"
left the Inbox row showing a dot while the numeral and dock badge stayed at zero;
the exclusion now yields to that override. And the CLI's skew guard measured the
wrong quantity: stamping `stored.created_at + 1` is only ever one second ahead of
whoever wrote the stored copy, so a 5s bound against *our* clock refused writes
the relay would have accepted and made `set-add-policy` unusable wherever the
harness host's clock leads the operator's. Bounded against the relay's ±900s
tolerance instead, and the sleep dropped — it only delayed the command.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
…ling

Cycle 16's cold review showed the previous commit traded one bug for a worse
one, and that the fix it replaced was correct once its actual hazard was removed.

Handing an unresolvable reply to the live path loses the notification instead of
deferring it. `collectHomeAlertItems` returns the whole mention list unfiltered,
and the notification effect adds *every* item it saw — including the ones
`eligibleFeedNotificationItems` just declined as `replyToSelf` — to the persisted
seen set. So the declining poll consumes the slot, and when the next poll
resolves the parent the item is dropped as already-seen. A genuine typed
`@mention` posted inside a thread is silently lost for good, across restarts,
because its parent belongs to a third party and so looks unresolved. Events in
channels missing from the local list are not deferrable at all — the live path
returns early for those.

So propagating the error is right after all. What made it dangerous was the
malformed `e` tag, and validating the ids fixes that at the source: a permanently
poisoned filter is no longer possible, so the only remaining failures are
transient, and React Query keeps the previous data on error — the feed reference
does not change, the effect does not re-run, no id is consumed, and the next good
poll delivers.

Also: the `replyToSelf` re-test in the badge counting loop was not the harmless
redundancy its comment claimed. After the exclusion moved upstream, the only
replies reaching that loop are ones the user explicitly marked unread — and this
clause silently cancelled that override in every muted channel, leaving the Inbox
row dotted while the numeral read zero. The mute gate now lives next to the
exclusion it pairs with, and the test asserts both halves together; asserting
only the build step is what let the two cancel unnoticed.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
…eir case

Cycle 17 verified the previous commit's two claims end to end, then found the
validation I added to the Rust feed was missing from its three TypeScript
counterparts — and that the case of an event id was never normalized anywhere.

Any community member can publish an event whose `e` tag value 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 — no CLOSED,
no EOSE — and this client only resolves `rate-limited:` notices, so the request
hangs the full 25s history timeout before rejecting. One such event in a channel
made every parent lookup there fail: the catch-up released the channel, re-armed
its backoff, refetched 1000 events and failed again, so that channel had 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. The
community observer needs no `p` tag to hit it at all, and the live path burned
~77s per message.

Validation belongs at the filter boundary, not in `getThreadReference` — trying it
there broke 57 tests and taught me why: that function is the general
thread-grouping primitive, and a value that cannot identify a relay event is
still a perfectly good grouping key. So `normalizeEventId` gates the three
call sites that build filters, and `getThreadReference` keeps grouping whatever
it is given.

Case is the other half, and does belong in `getThreadReference` plus Rust's
`reply_parent_id`. Hex decodes case-insensitively, so an uppercase id passes
validation and the relay really does return the parent — but every comparison is
against `event.id`, which is always lowercase. The mismatch read as "parent
absent", which relabels a reply a real mention: the feed toasts it *and* the live
path fires its own thread-reply notification, two sounds for one message, and a
channel mute bypassed because mentions are exempt from it.

Also: `isBadgeCountableMention` had no DM carve-out, unlike every other path in
this feature. Every DM message p-tags both participants, so `replyToSelf` carries
no information there, and an answer inside a DM stopped counting toward the Home
numeral while the first message of the same conversation still counted. The
backend cannot help — it never populates `channel_type` on a feed item — so the
DM channel ids come from the channel list, keyed on a joined string because the
parameter defaults to a fresh array.

Test fixtures in the two parent-lookup suites used short readable ids, which the
new gate correctly skips; they now use real 64-hex ids.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
`shouldCountTowardHomeBadgeSubtotal` excluded DMs by testing `item.channelType`,
but the backend never populates that field — `feed_item_from_event` emits
`channel_type: None`, and unlike the toast path nothing enriches it from the
channel list here. So the guard was dead in production: a DM thread reply landed
in this subtotal while the channel-side count already had it, and
`useAppShellLifecycleEffects` adds the two, so the dock badge read 2 for one
message. The previous commit's DM carve-out widened the reach of that dead guard
rather than creating it — the `replyToSelf: false` sub-case was already exposed.

The channel list is the authoritative source, so pass the same `dmChannelIds` set
`isBadgeCountableMention` already uses.

The existing test asserted exactly this item shape returns false, and passed,
because it hand-supplied `channelType: "dm"` — a value production data never
carries. That is what kept the guard's deadness invisible. It now also asserts the
production shape, with `channelType: undefined` and the channel list supplying the
answer.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
trim_mentions_preferring_real sorted its survivors newest-first but
selected them in input order: partition preserves order, so both the
truncation and the reply fill-in took from the front of an unsorted
half. Given five replies and one mention with a cap of three it kept
the two *oldest* replies, and its own trim_returns_newest_first test
failed. It only looked correct because the relay answers created_at
DESC; nothing here should depend on that. Sort before selecting.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
recordMentionedRoot was called without the parent's author from two of
its three sites, so the guard that separates a typed `@mention` from a
reply's addressing p tag never ran there. One of them fires on the same
event moments after the guarded call declines it, which made the guard
dead in the live path; the other ran over the whole startup window and
persisted the result.

A thread whose root was recorded this way renders as "Following" and
hides its Follow action, while shouldNotifyForEvent — which never reads
that set — stays silent for every later reply. The user is shown as
subscribed to a thread they are not, with no way to actually subscribe.

Catch-up now records mentions after catchUpParentAuthors resolves,
not before it, and the live path forwards the author it already has.
DMs keep passing null: there the addressing tag is the addressing.

Both catch-up passes move to catchUpMembership.ts and the event scan to
catchUpScan.ts, keeping useUnreadChannels.ts under the size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
It lowercases the tag value; the comment claimed verbatim. The hex
validation on the next line is what the paragraph is actually about.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
isNotifiedForThread counts a mention as following the thread — it renders
"Following" and hides the Follow action — but shouldNotifyForEvent never
read that set. Being mentioned in a thread you had not otherwise touched
therefore removed the control that would have subscribed you, and every
later reply in it stayed silent. Subscribed in the UI, unsubscribed in
fact, with no way back.

mentionedRootIds is now a term in the gate too, below the mute checks so
an explicit thread mute still outranks the mention. Wired through the
live path, the catch-up scan, and the community observer, which reads the
same buzz-thread-mentioned.v1 key so a sidebar dot cannot disagree with
the in-app gate about one thread.

Pre-existing at the merge base; not introduced by this branch.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
A reply p-tags the author it answers so agent require_mention filters
deliver it. That tag is byte-identical to a typed @mention, so every
receiver had to fetch the parent message and check who wrote it just to
tell the two apart — a relay round trip to recover something the sender
knew for free, plus the caching, chunking, retry and fail-open machinery
that round trip needs.

Replies now mark each p tag with its role, in the fourth position, the
way e tags already carry root and reply:

  ["p", <parent author>, "", "reply"]     addressing
  ["p", <typed pubkey>,  "", "mention"]   a real @mention

Read one-way: a marker that is present is authoritative, an absent one
means "ask the parent" and never "this is a mention". Senders that
predate the markers keep working unchanged, and there is no flag day.
Relay filters match only a tag's second element, so #p delivery to
agents is untouched. Top-level messages are unmarked — a p tag there can
only be a mention.

This also settles the case inference could never reach: 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.

Emitted by all four senders: the Tauri backend (from the parent event
resolve_thread_ref already fetches, so no extra query — and unlike the
frontend's cache it never misses), buzz-sdk for the CLI and the ACP
harness, and mobile. Consumed by shouldNotify and the feed poll, where a
marked tag now skips the parent lookup entirely.

events.rs splits its tag builders into message_tags.rs to stay under the
size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
cyberzero000 added 3 commits August 18, 2026 15:49
Three defects in the p-tag role markers, all found reviewing 529987e.

A setup nudge answering an ask inside somebody else's thread pointed its
`e` tag at the thread root while naming the asker on the addressing p
tag. `ThreadRef.parent_author` is documented as the author of
`parent_event_id`, and both consumers act on exactly that: feed.rs turns
the addressing marker into reply_to_self, shouldNotify into
isReplyToCurrentUser. So the nudge claimed to answer a message the asker
had not written. If they had muted that thread it went out silently, and
it stopped counting toward the Inbox badge unconditionally. The nudge now
replies to the ask itself, rooted at the ask's thread, so the two fields
describe the same event.

A DM tags every other participant whether or not anyone typed their
names. Those pubkeys were folded into the mention list, so every DM
thread reply claimed its counterpart had been @-mentioned — a claim that
pierces a mute, and that puts the reply in the mention feed's
truncated-first bucket ahead of a real @you. Recipients now travel apart
from typed mentions and are emitted bare:

  ["p", pk, "", "mention"]   typed as @name
  ["p", pk, "", "reply"]     the author being answered
  ["p", pk]                  addressed by the channel

Bare is not a gap. Under the one-way read an absent marker means "ask the
parent", which is the answer these tags already got before markers
existed. The usual DM reply is unaffected in shape: the counterpart is
also the parent's author, so they get one tag, marked addressing.

resolve_thread_ref now drops its own pubkey from parent_author instead of
leaving that to callers. The forum-comment branch had not copied the
check, and while nostr scrubs a self p tag before signing, the cap
arithmetic runs first — so an identical message succeeded as a stream
reply and failed as a forum comment at 50 mentions.

The e2e bridge carries the recipient list too, so a mocked DM reply keeps
tagging its counterpart. It still emits no markers, which reads as
`unknown` — the pre-marker path, not a wrong answer.

events.rs splits the NIP-IA builders into events/identity_archive.rs to
stay under the size ratchet.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
…owner

Follow-ups on f9849ef.

Moving the self-null into resolve_thread_ref made it compare the parent's
author against `state.keys` — the desktop owner. Two of the three callers
do sign with that key; send_managed_agent_channel_message signs with the
agent's own. So a managed agent replying to a message the owner wrote had
the owner's addressing tag stripped, and with no typed mentions the reply
carried no p tag at all: no `#p` delivery to the owner, no "someone
replied to you". The comparison now takes the signing key as an argument.
An unknown signer is None, which leaves the tag in place, so the
lock-poisoning path behaves as it did before.

The e2e bridge grew the recipient list on two of its three send branches.
The third is the relay-identity branch the integration Playwright project
takes, where a DM sent through the composer was losing its counterpart p
tag outright.

The new nudge test tagged root and reply with the same all-zeros id, so
its root_event_id assertion held whichever tag the code read. Distinct
ids now, and only then does it pin the fix.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
85 commits of main, 23 overlapping files, 5 conflicts. None of the three
defects this branch fixes has an upstream equivalent: buildReplyTags still
takes no parent author, relayAgentCanRespondInChannel still gates on the
self-declared channel_ids, and set-add-policy still publishes the policy
field as the whole kind:10100 content.

Resolutions, all keeping both sides:

- resolve_thread_ref moved upstream into commands/messages/thread_ref.rs
  and gained api_base_url + a pinned `keys` snapshot for the read's NIP-98
  auth. Took main's module and signature; ported parent_author and the
  self-reply suppression into it. `keys` and the new `signer` argument are
  separate on purpose — a managed agent reads as the active identity but
  signs as itself, so one key authenticates the read and the other decides
  whether the addressing tag is answering ourselves. self_pubkey is gone:
  state.signing_keys() is the authoritative signer and accounts for
  recovery mode.
- sendChannelMessage moved to shared/api/tauriMessages.ts and gained
  expectedRelayUrl + expectedSignerPubkey. recipientPubkeys appended after
  them so main's positional callers are untouched.
- relayAgentCanRespondInChannel's Pick keeps main's ownerPubkey (owner-only
  gating) alongside this branch's pubkey (relay-membership eligibility).
- The mobile thread composer keeps main's AndroidImeLift, focus wiring and
  `channel:` argument, plus this branch's parentAuthorPubkey — without that
  line the reply carries no addressing tag and never reaches the agent.
- InboxDetailPane's new video-review comment path now resolves the author
  it answers, the same way the composer path already did.

Main also grew mobile/lib/features/channels/message_mention_pubkeys.dart,
which folds DM recipients into the mention list — the conflation this
branch had just removed on desktop. The conflict could not be resolved
without splitting it, so it is now message_recipients.dart returning
{mentions, addressed}, and the send provider emits channel recipients bare
while a counterpart who wrote the parent keeps the `reply` marker. Two
tests cover both DM reply shapes.

Verified after the merge: workspace and Tauri clippy at -D warnings, both
fmt checks, 804/350/262 Rust tests, 5196 desktop JS, tsc, biome, px-text
and both file-size guards, dart format, flutter analyze, 1550 mobile.
Relay tag filters still match only a tag's second element
(crates/buzz-core/src/filter.rs:75), so #p delivery to agents is unchanged.

Signed-off-by: cyberzero000 <user1@cyberzerosystems.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant