fix(agents): track channel membership, not a stale profile copy - #6577
fix(agents): track channel membership, not a stale profile copy#6577cyberzero000 wants to merge 2 commits into
Conversation
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>
Review auditTen findings from a review of PremiseF1 — the desktop premise was wrong. F2 — what the desktop change actually closes is a refresh-clock gap. F3 — the new tests encoded an unreachable state. F5 — mobile's picker does gate on kind:10100 DefectsF4 — F6 — no tests for 290 lines of queue, LWW-timestamp and response-parsing F7 — the skew guard was 180x tighter than the constraint it modelled. F8 — Also fixed while in there: a burst that nets to no change no longer costs a Left openF9 — a delta arriving during the retry backoff wins the F10 — add/remove asymmetry. member-added with no matching rule records no |
kind:10100's
channel_idsis what mobile's mention picker consults to decidewhether a non-member relay agent is mentionable (
agentIsSharedWithUserinmobile/lib/features/channels/mentions/mention_candidates.dart). Nothing keptthat array in sync with membership:
buzz-acponly read channels, and the relaynever 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_agentsreplaceschannel_idswith relay membership read fromrelay-signed kind:39002 before the frontend ever sees it
(
desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs), so theprofile's copy never reaches the picker.
Fixed on both sides
buzz-acpupdates its profile when it observes a membership change it alreadyhandles. 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 anychannel 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 leftmissing: creating one belongs to deploy tooling, and a partial profile invented
here would read as a downgrade to clients.
channel_idskeeps its existing order — adds append, removes delete in place,and the dedup is order-preserving. It is paired with the human-readable
channelsarray by index inderiveProfileChannels, so sorting it relabelsrows that were correct before.
Desktop accepts the live channel roster in place of
channelIds. Both arerelay membership, but they refresh on different clocks:
useRelayAgentsQuerypolls every
AGENTS_FOCUS_STALE_TIME_MS(5 min) withrefetchOnWindowFocus: false, while the roster updates live. An agent invitedto the channel you are looking at is missing from the picker until the next
tick. Send-time was never affected —
revalidate_relay_agentsqueries#d: [channel_id]fresh.respond_toandrespondToAllowlistare stillenforced, 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:
later write would drop the earlier channel.
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_deltascompares the final arrayagainst 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_idswrong indefinitely behinda single warn line.
Timestamps
The stored profile's
created_atis a floor — anyone may have published it, andour 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: truewithduplicateorduplicate: …— stored and then rolledback because a peer's copy won the replaceable compare.
acceptedfield —RestClient::submit_eventsurfaces an empty2xx 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 writepath matches the same forms.
Also fixed: the desktop index pairing
deriveProfileChannelspairedchannels[i]withchannelIds[i]and used theresult as the navigation target, so a row could read
#generaland open#random. This was already wrong before this PR — the backend returnschannel_idsin channel-id order against whatever order the profile's names arein. 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 warningsandcargo fmtclean.pnpm test(desktop) — 5403 passed, 0 failed.tsc --noEmitandbiome checkclean.apply_channel_id_deltas,profile_publish_stamp, andprofile_write_landed: append-not-sort, later-delta-wins, no-op andnet-zero bursts, floor stepping, peer skew inside and outside the relay
window, and every fail-closed response shape.
relayAgentCanRespondInChannelto consult onlychannelIdsdrops two of that file's 34 tests; revertingderiveProfileChannelsto the index pairing drops two of its three.Known gaps
immediately, so a persistently failing publish under churn retries hot.
always does, so
channel_idscan still omit a channel the agent is a relaymember of.
Note
Split out of #5806. It stands alone and has no dependency on the rest of that
branch.