Summary
On a second device belonging to the same owner, the @ mention picker offers a persona as mintable even when an instance of that persona is already a member of the channel and running elsewhere. Selecting it creates a brand-new agent identity (new keypair, new kind:30177) instead of p-tagging the existing one.
The information needed to prevent this is already on the wire — ManagedAgentEventContent.persona_id — but the receiving device discards the whole event because it has no local record to merge into, so it never learns the pubkey → persona_id mapping that the picker's dedup depends on.
Line numbers below are from v0.5.0.
Repro
- Mac A: create persona Penelope, mint an instance, add it to
#channel. Works.
- Mac B: same owner account, app freshly launched. The persona definition (
kind:30175) syncs and auto-inserts. No local instance is created (by design).
- Mac B: in
#channel, type @Pen…. The picker shows two entries both labeled "Penelope" — the channel member, and a mintable persona.
- Selecting the persona entry mints a second identity for the same persona and attaches it to the channel.
Now every @Penelope is a coin flip between two identities, and messages addressed to the one whose harness lives on the other machine are silently dropped.
Root cause
agentIdentityKey in desktop/src/features/agents/lib/agentAutocompleteEligibility.ts:114 is what should collapse these two candidates:
if (candidate.personaId) {
return `persona:${candidate.personaId}`;
}
and agentCandidateRank (same file, :158) already ranks isMember first, so on a merge the live channel member would correctly win over the mintable persona.
The merge never happens because the member candidate has no personaId on device B. desktop/src/features/messages/lib/useMentions.ts:316:
personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? linkedPersonaId,
managedAgentPersonaIdsByPubkey is derived from the local managed-agent table, which on device B has no row for that pubkey. linkedPersonaId only resolves when a persona id happens to equal the agent pubkey, which is never true for minted instances. So the member candidate keys on label instead, the persona candidate keys on persona:<uuid>, and both survive into the list.
The persona is offered as mintable because the same local table gates it (useMentions.ts:389):
const personaCandidates = activePersonas
.filter((persona) => !managedAgentPersonaIds.has(persona.id))
and selecting it routes to createPersonaAgentMutation in desktop/src/features/messages/ui/useMentionSendFlow.ts:354.
Why the local table is empty — and why that's the fixable part
apply_inbound_managed_agent in desktop/src-tauri/src/commands/personas/mod.rs:839 drops the inbound kind:30177 projection entirely when there is no local match:
/// No match is a no-op: managed agents carry device-local secrets and are never
/// minted from a relay event — an agent that does not already exist locally has
/// no secret key to run with, so inserting a secretless shell would be useless
/// and misleading. This diverges from the persona path, which DOES insert on no
/// match (personas are secretless definitions).
The security reasoning is right and shouldn't change: device B must not fabricate a runnable record it has no key for. But the projection it throws away contains persona_id, which is not a secret and is exactly the join key the picker needs. Dropping the event is stricter than dropping the secrets.
Impact
- Duplicate identities accumulate silently; each
@mention from the non-minting device creates another.
- Mentions become nondeterministic once duplicates exist.
- Messages to the identity whose harness lives on the other machine are dropped with no error (correctly —
useMentionSendFlow.ts:260 skips non-local agents — but the user sees only silence).
- Related second-order failure: if device B has a stale local record (from an earlier accidental mint) whose harness was since deleted, the mention resolves to it and device B attempts a local
startAgentMutation (useMentionSendFlow.ts:265), producing cannot spawn agent <pubkey>: harness "<name>" was deleted.
Suggested fix
Keep the no-mint rule; record the association only.
On no-match in apply_inbound_managed_agent, persist the (pubkey, persona_id, name) triple into a separate non-runnable index rather than into managed-agents.json. Then source managedAgentPersonaIdsByPubkey from local records ∪ that index. The existing agentIdentityKey / agentCandidateRank logic then does the right thing with no further change: the two candidates collapse and the channel member wins.
A narrower alternative, if a new store is unwelcome: have the relay agent directory (relayAgentsQuery) carry persona_id, so useMentions.ts:341 and :316 can resolve it without any local state. That fixes the picker but leaves other cross-device persona↔instance lookups unaware.
Either way the desired end state is the one the code already reaches for on a single device — a persona with a live instance anywhere in the channel is tagged, not re-minted.
Environment
- Buzz desktop v0.5.0, two macOS machines, one owner account
- Self-hosted relay (
ghcr.io/block/buzz), agent runs under a custom harness on machine A
Summary
On a second device belonging to the same owner, the
@mention picker offers a persona as mintable even when an instance of that persona is already a member of the channel and running elsewhere. Selecting it creates a brand-new agent identity (new keypair, newkind:30177) instead of p-tagging the existing one.The information needed to prevent this is already on the wire —
ManagedAgentEventContent.persona_id— but the receiving device discards the whole event because it has no local record to merge into, so it never learns thepubkey → persona_idmapping that the picker's dedup depends on.Line numbers below are from v0.5.0.
Repro
#channel. Works.kind:30175) syncs and auto-inserts. No local instance is created (by design).#channel, type@Pen…. The picker shows two entries both labeled "Penelope" — the channel member, and a mintable persona.Now every
@Penelopeis a coin flip between two identities, and messages addressed to the one whose harness lives on the other machine are silently dropped.Root cause
agentIdentityKeyindesktop/src/features/agents/lib/agentAutocompleteEligibility.ts:114is what should collapse these two candidates:and
agentCandidateRank(same file, :158) already ranksisMemberfirst, so on a merge the live channel member would correctly win over the mintable persona.The merge never happens because the member candidate has no
personaIdon device B.desktop/src/features/messages/lib/useMentions.ts:316:managedAgentPersonaIdsByPubkeyis derived from the local managed-agent table, which on device B has no row for that pubkey.linkedPersonaIdonly resolves when a persona id happens to equal the agent pubkey, which is never true for minted instances. So the member candidate keys on label instead, the persona candidate keys onpersona:<uuid>, and both survive into the list.The persona is offered as mintable because the same local table gates it (
useMentions.ts:389):and selecting it routes to
createPersonaAgentMutationindesktop/src/features/messages/ui/useMentionSendFlow.ts:354.Why the local table is empty — and why that's the fixable part
apply_inbound_managed_agentindesktop/src-tauri/src/commands/personas/mod.rs:839drops the inboundkind:30177projection entirely when there is no local match:The security reasoning is right and shouldn't change: device B must not fabricate a runnable record it has no key for. But the projection it throws away contains
persona_id, which is not a secret and is exactly the join key the picker needs. Dropping the event is stricter than dropping the secrets.Impact
@mentionfrom the non-minting device creates another.useMentionSendFlow.ts:260skips non-local agents — but the user sees only silence).startAgentMutation(useMentionSendFlow.ts:265), producingcannot spawn agent <pubkey>: harness "<name>" was deleted.Suggested fix
Keep the no-mint rule; record the association only.
On no-match in
apply_inbound_managed_agent, persist the(pubkey, persona_id, name)triple into a separate non-runnable index rather than intomanaged-agents.json. Then sourcemanagedAgentPersonaIdsByPubkeyfrom local records ∪ that index. The existingagentIdentityKey/agentCandidateRanklogic then does the right thing with no further change: the two candidates collapse and the channel member wins.A narrower alternative, if a new store is unwelcome: have the relay agent directory (
relayAgentsQuery) carrypersona_id, souseMentions.ts:341and:316can resolve it without any local state. That fixes the picker but leaves other cross-device persona↔instance lookups unaware.Either way the desired end state is the one the code already reaches for on a single device — a persona with a live instance anywhere in the channel is tagged, not re-minted.
Environment
ghcr.io/block/buzz), agent runs under a custom harness on machine A