fix: agent mentions and thread replies that never reached the agent - #5806
Draft
cyberzero000 wants to merge 30 commits into
Draft
fix: agent mentions and thread replies that never reached the agent#5806cyberzero000 wants to merge 30 commits into
cyberzero000 wants to merge 30 commits into
Conversation
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
marked this pull request as draft
August 13, 2026 22:36
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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) emitetags for the thread andptags for explicit mentions, but never aptag for the author being replied to. NIP-10 says a reply should carry one.That omission is not cosmetic here.
require_mentionsubscriptions add#pto 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
buildReplyTagsalready 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
relayAgentCanRespondInChannelgates onagent.channelIds.includes(channelId)— thechannel_idsarray from the agent's kind:10100 profile. Nothing keeps that array in sync with membership:buzz-acponly 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 (
messageMentionPubkeysreturns only explicit mentions outside DMs). The agent is present, listening, and unmentionable. Mobile never had the bug because it resolves@nameagainst relay membership (send_message_provider.dart).Fixed on both sides, either of which is sufficient alone:
channel_ids. Membership is maintained by the relay and cannot drift.respond_toandrespondToAllowlistare still enforced, so this widens discovery, not authority.buzz-acpupdates 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 bychannels_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-policyerases the agent profilecmd_set_add_policypublished 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_nameandrespond_tolive 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
ptag with the role it plays, in the fourth position, the wayetags 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 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, markedreply.Four properties make this safe to land without coordinating clients:
#pdelivery 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.ptag there can only be a mention.Emitted by all four senders: the Tauri backend,
buzz-sdkfor the CLI and the ACP harness, and mobile's channel and forum providers.messageRecipientson both desktop and mobile returns the two groups separately. Consumed byshouldNotifyand 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_authormust be the author ofparent_event_idand 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_refalready fetches the parent event on its way to the thread root, soparent.pubkeycosts 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
ptag 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:@mentioninside a muted thread was reported as notifying but did not.etag rather than rejecting it, and feeding that value back in anidsfilter returned a bareNOTICE, hanging the client for 25s. One failing query no longer takes down the whole feed.Testing
channelIds, membership not bypassing an allowlist, and a mention piercing a mute on a thread you started.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-sizesall clean.desktop-tauri-testcannot run on this machine —sherpa-onnx-c-apiis missing and the crate fails at link time, unrelated to this branch.desktop-tauri-clippypasses. 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 toevents/message_tags.rs, then the NIP-IA builders toevents/identity_archive.rs(876).desktop/src/features/channels/useUnreadChannels.ts→catchUpMembership.ts+catchUpScan.ts.desktop/src/features/messages/useMentions.ts→ the two identical "normalise every pubkey in this list" blocks becamenormalizePubkeySetinshared/lib/pubkey.ts(995).Merged with main
mainmoved 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_refmoved upstream intocommands/messages/thread_ref.rsand gained a pinnedkeyssnapshot 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.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 hasmessage_recipients.dartreturning{mentions, addressed}to match desktop, with tests for both DM reply shapes.relayAgentCanRespondInChannelkeeps main'sownerPubkeyowner-only gating alongside this branch's relay-membership eligibility.🤖 Generated with Claude Code