Skip to content

fix(agents): track channel membership, not a stale profile copy - #6577

Draft
cyberzero000 wants to merge 2 commits into
block:mainfrom
cyberzero000:fix/agent-mention-eligibility
Draft

fix(agents): track channel membership, not a stale profile copy#6577
cyberzero000 wants to merge 2 commits into
block:mainfrom
cyberzero000:fix/agent-mention-eligibility

Conversation

@cyberzero000

@cyberzero000 cyberzero000 commented Aug 22, 2026

Copy link
Copy Markdown

kind:10100's channel_ids is what mobile's mention picker consults to decide
whether a non-member relay agent is mentionable (agentIsSharedWithUser in
mobile/lib/features/channels/mentions/mention_candidates.dart). Nothing kept
that array in sync with membership: buzz-acp only read channels, and the relay
never authors a 10100. So an agent invited to a new channel subscribed
immediately and answered anything p-tagged, but stayed absent from mobile's
picker for that channel until an operator republished the profile by hand.

What this does not fix

An earlier revision of this PR claimed desktop had the same bug. It does not.
list_relay_agents replaces channel_ids with relay membership read from
relay-signed kind:39002 before the frontend ever sees it
(desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs), so the
profile's copy never reaches the picker.

Fixed on both sides

buzz-acp updates its profile when it observes a membership change it already
handles.
This is the half with a real consumer.

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. The profile is
read-modify-written so fields the harness does not own (display_name,
capabilities, channel_add_policy) survive. A missing profile is left
missing: creating one belongs to deploy tooling, and a partial profile invented
here would read as a downgrade to clients.

channel_ids keeps its existing order — adds append, removes delete in place,
and the dedup is order-preserving. It is paired with the human-readable
channels array by index in deriveProfileChannels, so sorting it relabels
rows that were correct before.

Desktop accepts the live channel roster in place of channelIds. Both are
relay membership, but they refresh on different clocks: useRelayAgentsQuery
polls every AGENTS_FOCUS_STALE_TIME_MS (5 min) with
refetchOnWindowFocus: false, while the roster updates live. An agent invited
to the channel you are looking at is missing from the picker until the next
tick. Send-time was never affected — revalidate_relay_agents queries
#d: [channel_id] fresh. respond_to and respondToAllowlist are still
enforced, so this widens discovery, not authority, and there is a test for
exactly that.

Why the delta queue has a single worker

kind:10100 is replaceable and read-modify-written, so:

  • Two concurrent appliers would both start from the pre-change profile, and the
    later write would drop the earlier channel.
  • Two out-of-order appliers for the same channel settle on the wrong answer — an
    add landing after its own remove leaves the agent advertising a channel it
    left.

One worker draining an ordered queue gives both properties. Queued deltas are
coalesced into a single publish, because membership churn arrives in bursts (a
bulk invite queues one delta per channel within milliseconds) and each
replaceable write needs its own second — publishing them one at a time would
demand more distinct seconds than the burst spans. A burst that nets to no
change costs no write at all: apply_channel_id_deltas compares the final array
against the original rather than counting mutations.

A failed publish keeps its deltas and retries with backoff. The queue is the
only copy, so dropping them would leave channel_ids wrong indefinitely behind
a single warn line.

Timestamps

The stored profile's created_at is a floor — anyone may have published it, and
our write must not tie with theirs, because the relay breaks a same-second
replaceable tie by lowest event id and rolls the loser back while still
reporting the write as accepted.

Two different leads reach the publish path and only one is worth waiting out.
Our own previous publish is one second back; sleeping through it is what keeps
the one-per-second cap a rate limit instead of data loss, and it hands the
second back before returning so stamps never ratchet away from the wall clock.
A peer's future timestamp is out-bid by stamping past it, up to the relay's own
±900s ingest window — not slept through, which would park the single worker for
minutes. Past that window the event cannot land at all, so the worker fails
loudly, keeps the deltas, and retries.

Write confirmation

A 2xx does not mean the profile changed, and all three ways it can fail are
treated as failure:

  • accepted: false — refused outright.
  • accepted: true with duplicate or duplicate: … — stored and then rolled
    back because a peer's copy won the replaceable compare.
  • no readable accepted field — RestClient::submit_event surfaces an empty
    2xx body as Value::Null.

It fails closed. The queue is the worker's only copy of these deltas, so reading
an unreadable response as success would drop them silently. buzz-cli's write
path matches the same forms.

Also fixed: the desktop index pairing

deriveProfileChannels paired channels[i] with channelIds[i] and used the
result as the navigation target, so a row could read #general and open
#random. This was already wrong before this PR — the backend returns
channel_ids in channel-id order against whatever order the profile's names are
in. Both sides now resolve against the community channel list instead of by
position.

Testing

  • cargo test -p buzz-acp --lib — 812 passed, 0 failed. cargo clippy -p buzz-acp --all-targets -D warnings and cargo fmt clean.
  • pnpm test (desktop) — 5403 passed, 0 failed. tsc --noEmit and biome check clean.
  • 11 tests cover apply_channel_id_deltas, profile_publish_stamp, and
    profile_write_landed: append-not-sort, later-delta-wins, no-op and
    net-zero bursts, floor stepping, peer skew inside and outside the relay
    window, and every fail-closed response shape.
  • Mutation-checked: reverting relayAgentCanRespondInChannel to consult only
    channelIds drops two of that file's 34 tests; reverting
    deriveProfileChannels to the index pairing drops two of its three.

Known gaps

  • A delta arriving during the retry backoff cancels it and republishes
    immediately, so a persistently failing publish under churn retries hot.
  • member-added with no matching rule records no delta while member-removed
    always does, so channel_ids can still omit a channel the agent is a relay
    member of.

Note

Split out of #5806. It stands alone and has no dependency on the rest of that
branch.

Desktop's `@mention` picker gated on `agent.channelIds.includes(channelId)` —
the `channel_ids` array from the agent's kind:10100 profile. Nothing kept 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 subscribed immediately and answered
anything p-tagged, but never appeared in that channel's picker, and typed text
was not consulted outside DMs. The agent was present, listening, and
unmentionable until an operator republished the profile by hand. Mobile never
had the bug because it resolves `@name` against relay membership.

Fixed on both sides, either 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. 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.

Deltas drain through a single ordered worker. kind:10100 is replaceable and
read-modify-written, so two concurrent appliers would both start from the
pre-change profile and the later write would drop the earlier channel, and two
out-of-order appliers for the same channel would settle on the wrong answer.
Queued deltas are coalesced into one publish because membership churn arrives
in bursts and each replaceable write needs its own second. A failed publish
keeps its deltas and retries with backoff rather than dropping them behind a
single warn line.

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

Review of the first commit found the desktop premise wrong and three real
defects in the harness path.

`channel_ids` was never stale on desktop. `list_relay_agents` replaces the
field with relay membership read from relay-signed kind:39002 before the
frontend sees it (`relay_directory.rs`), so the kind:10100 array never reaches
the picker. What the desktop change actually closes is a refresh-clock gap:
`useRelayAgentsQuery` polls every 5 minutes with `refetchOnWindowFocus: false`
while the channel roster updates live, so an agent invited to the channel you
are looking at is missing from the picker until the next tick. Send-time was
never affected — `revalidate_relay_agents` queries `#d: [channel_id]` fresh.
Comments and test names now say that instead.

The consumer that does read kind:10100 `channel_ids` is mobile's picker, for
non-member relay agents (`agentIsSharedWithUser` in `mention_candidates.dart`).
That is what the harness half repairs.

Three fixes to that half:

- `ids.sort()` is gone. `channel_ids` is paired with the profile's
  human-readable `channels` array by index in `deriveProfileChannels`, so
  sorting relabelled rows that were correct before. Adds append, removes
  delete in place, and the dedup preserves order.
- The skew guard was 5s against a relay that accepts ±900s. A peer stamped
  6-900s ahead is publishable, but the guard refused and retried forever at
  the 300s backoff ceiling, leaving `channel_ids` stale for the life of the
  process. Raised to the relay's own window, with the wait it can cost bounded
  separately: only our own previous publish is worth sleeping through (one
  second), and a peer's future stamp is out-bid rather than slept through, so
  the single worker is never parked for minutes.
- `accepted` failed open. `!= Some(false)` read a missing field as success,
  and `submit_event` returns `Value::Null` for an empty 2xx body — the worker
  would clear deltas it never landed, and the queue is their only copy. Now
  fails closed and also matches the bare `duplicate` message the relay emits
  alongside `duplicate:`, as `buzz-cli`'s write path does.

A burst that nets to no change no longer costs a replaceable write: `changed`
compares the final array against the original rather than counting mutations.

The desktop index-pairing is fixed too, not just stopped from getting worse.
The backend already returns `channel_ids` in channel-id order against
whatever order the profile's `channels` names are in, so the pairing was
already wrong: a row could read `#general` and open `#random`. Both sides now
resolve against the community channel list instead of by position.

Tests: 11 for the extracted `apply_channel_id_deltas`,
`profile_publish_stamp`, and `profile_write_landed`, where the first commit
had none for 290 lines of queue, LWW-timestamp and response-parsing logic.
3 for `deriveProfileChannels`; 2 of those fail against the index-paired
version.

cargo test -p buzz-acp --lib: 812 passed, 0 failed.
cargo clippy -p buzz-acp --all-targets -D warnings: clean. cargo fmt: clean.
desktop: 5403 passed, 0 failed. tsc --noEmit and biome check: clean.

Still open, deliberately: a delta arriving during the retry backoff cancels it
and republishes immediately, so a persistent failure under churn retries hot.
And member-added with no matching rule records no delta while member-removed
always does, so `channel_ids` can still omit a channel the agent is a relay
member of.

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

Copy link
Copy Markdown
Author

Review audit

Ten findings from a review of 94c533b37. Eight are addressed in 514a099aa;
two are left open on purpose. Recorded here so the reasoning survives the
rewrite of the description.

Premise

F1 — the desktop premise was wrong. list_relay_agents replaces
agent.channel_ids wholesale with relay membership derived from relay-signed
kind:39002, filtered #p: [viewer]
(desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs:242-247,
via member_agent_channel_ids_from_events). The kind:10100 array never reaches
the frontend, so agent.channelIds.includes(channelId) was never the stale
field, and "unmentionable until an operator republishes by hand" never applied
to desktop. Fixed: comments corrected.

F2 — what the desktop change actually closes is a refresh-clock gap.
useRelayAgentsQuery polls at AGENTS_FOCUS_STALE_TIME_MS = 5 * 60_000 with
refetchOnWindowFocus: false (desktop/src/features/agents/hooks.ts:107,358)
while the channel roster updates live. Send-time was never affected —
revalidate_relay_agents queries #d: [channel_id] fresh. Real gap, worth
closing; five minutes, not indefinite. Fixed: documented as such.

F3 — the new tests encoded an unreachable state. channelIds: ["general"]
for an agent the relay lists in "fresh" is not something the backend produces.
The tests pass and pin the new path, but they did not demonstrate the described
bug. Fixed: renamed and re-commented to the poll-window framing.

F5 — mobile's picker does gate on kind:10100 channel_ids, for non-member
relay agents (agentIsSharedWithUser,
mobile/lib/features/channels/mentions/mention_candidates.dart:19). That is the
real justification for the buzz-acp half, and the description argued against
its own strongest case. Fixed: it is now the stated reason.

Defects

F4 — ids.sort() desynchronized channel_ids from the index-paired
channels array.
deriveProfileChannels
(desktop/src/features/profile/ui/UserProfilePanelUtils.ts) paired them by
index and fed the result to onOpenChannel, so a row could read #general and
open #random. Pre-existing — the backend already returns ids in channel-id
order against unsorted names — but the code comment's "nothing gates on it" was
wrong. Fixed: sort removed, and the index pairing replaced with lookups against
the community channel list on both sides.

F6 — no tests for 290 lines of queue, LWW-timestamp and response-parsing
logic.
Fixed: apply_channel_id_deltas, profile_publish_stamp and
profile_write_landed extracted, 11 tests added.

F7 — the skew guard was 180x tighter than the constraint it modelled.
MAX_PROFILE_PUBLISH_SKEW_SECS = 5 against the relay's
MAX_TIMESTAMP_DRIFT_SECS = 900
(crates/buzz-relay/src/handlers/ingest.rs:2224). A profile stamped 6-900s
ahead by a peer on a skewed clock is publishable, but the guard refused it and
retried forever at the 300s ceiling — channel_ids stale for the life of the
process. Fixed: raised to 900, with the wait it can cost bounded separately at
one second so the single worker is never parked for minutes.

F8 — accepted failed open. .and_then(as_bool) != Some(false) read a
missing field as success, and submit_event returns Value::Null for an empty
2xx body, so the worker could clear deltas it never landed. It also missed the
bare duplicate message the relay emits alongside duplicate:
crates/buzz-cli/src/commands/mod.rs:82-95 handles both and fails closed.
Fixed: matches that behaviour.

Also fixed while in there: a burst that nets to no change no longer costs a
replaceable write — changed compares the final array against the original
instead of counting mutations.

Left open

F9 — a delta arriving during the retry backoff wins the select! and
triggers an immediate republish, discarding the backoff. A persistent failure
under churn retries hot.

F10 — add/remove asymmetry. member-added with no matching rule records no
delta; member-removed always records one. So channel_ids can permanently omit
a channel the agent is a relay member of, which is the same staleness this PR
set out to remove, still open for mobile's picker.

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