From 407a5fd52061580a30746899ec324d739afc44fc Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 22 Oct 2026 14:39:46 -0400 Subject: [PATCH 1/3] fix(agents): let Delete finish when the agent is in archived channels The relay refuses membership changes in an archived channel ("invalid: channel is archived"), but Delete required a fresh roster read to show the agent in none of its channels. An agent left in any archived channel could therefore never be deleted. Skip archived channels when collecting the memberships to remove: the relay keeps them inert, and Delete still archives the identity and removes its key. Co-authored-by: Oliver Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/features/agents/relay-removal.test.ts | 21 ++++++++++++++ src/features/agents/relay-removal.ts | 10 ++++--- src/features/relay/channel-writes.ts | 34 +++++++++++++++++++++-- src/features/relay/testing.ts | 9 ++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/features/agents/relay-removal.test.ts b/src/features/agents/relay-removal.test.ts index 859bf4ac3f..f466dbc964 100644 --- a/src/features/agents/relay-removal.test.ts +++ b/src/features/agents/relay-removal.test.ts @@ -147,6 +147,27 @@ it("a refused channel removal does not fail Remove or leave outbox work", async expect(instance.session.outbox?.snapshot()).toEqual([]); }); +it("skips archived channels, where the relay refuses membership changes", async () => { + const active = `${"a".repeat(8)}-channel`, + archived = `${"b".repeat(8)}-channel`; + const { fixture, instance } = setup(viewer, { + [active]: [agent.pubkey, viewer.pubkey], + [archived]: [agent.pubkey, viewer.pubkey], + }); + fixture.archivedChannels.add(archived); + await removeAgentFromChannels( + instance.session, + agent.pubkey, + new AbortController().signal, + ); + expect(fixture.published.map((event) => event.tags[0])).toEqual([ + ["h", active], + ]); + expect(fixture.channels[active]).not.toContain(agent.pubkey); + expect(fixture.channels[archived]).toContain(agent.pubkey); + expect(instance.session.outbox?.snapshot()).toEqual([]); +}); + it("signs a new channel removal instead of resending one the relay would refuse as stale", async () => { const { fixture, instance } = setup(); const removeFromChannels = () => diff --git a/src/features/agents/relay-removal.ts b/src/features/agents/relay-removal.ts index 75e126a79e..cadfe72d02 100644 --- a/src/features/agents/relay-removal.ts +++ b/src/features/agents/relay-removal.ts @@ -151,9 +151,9 @@ export async function deleteAgentRecord( } /** Base Buzz `removeAgentFromAllChannels` over the relay's rosters for this - * agent plus the viewer's loaded channels. Resolves only once a fresh relay - * read lists the agent in none of them; any refusal or unknown outcome throws - * after every channel operation has settled. */ + * agent plus the viewer's loaded channels, skipping archived ones. Resolves + * only once a fresh relay read lists the agent in none of them; any refusal or + * unknown outcome throws after every channel operation has settled. */ export async function removeAgentFromChannels( session: Pick, pubkey: string, @@ -163,7 +163,9 @@ export async function removeAgentFromChannels( ...(await session.channelWrites.memberChannels(pubkey, signal)), ...session.channels .list() - .channels.filter((channel) => channel.members?.includes(pubkey)) + .channels.filter( + (channel) => !channel.archived && channel.members?.includes(pubkey), + ) .map((channel) => channel.id), ]); if (!pending.size) return; diff --git a/src/features/relay/channel-writes.ts b/src/features/relay/channel-writes.ts index 7488056404..c1e2e12f61 100644 --- a/src/features/relay/channel-writes.ts +++ b/src/features/relay/channel-writes.ts @@ -1,4 +1,5 @@ import { canonicalDetailsName } from "./channel-details-protocol"; +import { exactLifecycleTag } from "./channel-lifecycle-protocol"; import { channelIdentityUnsupported, channelLabelTags } from "./channel-labels"; import { SOLO_NAMED_LABEL, SOLO_LABEL } from "../solo/labels"; import type { Outbox } from "./outbox"; @@ -341,8 +342,10 @@ export function createChannelWrites( ); return channel; } - /** Channel IDs whose relay-signed roster lists `pubkey`, as far as this viewer - * may read. Throws rather than return a possibly truncated set. */ + /** Unarchived channel IDs whose relay-signed roster lists `pubkey`, as far + * as this viewer may read. The relay refuses membership changes in an + * archived channel, where a membership is inert. Throws rather than return a + * possibly truncated set. */ async function memberChannels(pubkey: string, caller: AbortSignal) { if (!relayAuthor) throw new Error("Channel membership is unavailable."); const limit = 500; @@ -375,6 +378,33 @@ export function createChannelWrites( ) ids.add(id); } + if (!ids.size) return []; + const metadata = await discovery.read( + [ + { + kinds: [39000], + authors: [relayAuthor], + "#d": [...ids], + limit: ids.size, + consistency: "strong", + }, + ], + { + signal: AbortSignal.any([signal, caller]), + fresh: true, + priority: "foreground", + }, + ); + for (const event of metadata) { + const id = event.tags.find(([name]) => name === "d")?.[1]; + if ( + id && + event.kind === 39000 && + event.pubkey === relayAuthor && + exactLifecycleTag(event, "archived") === "true" + ) + ids.delete(id); + } return [...ids]; } /** Whether this channel's current relay-signed roster lists `pubkey`. Throws diff --git a/src/features/relay/testing.ts b/src/features/relay/testing.ts index 799f39db18..b98aa1db09 100644 --- a/src/features/relay/testing.ts +++ b/src/features/relay/testing.ts @@ -5,6 +5,7 @@ import { type EventTemplate, } from "nostr-tools"; import type { ReadFilter, RelayEvent } from "./events"; +import { PublishRejected } from "./outbox"; import type { ReadTransport, Signer } from "./transport"; /** Test-only signing helpers. Real signatures so the transport's verification stays on. */ @@ -175,6 +176,8 @@ export function archiveRelay( channels: Record = {}, ) { const archived = new Set(); + /** Channel IDs whose metadata is archived; the relay refuses 9001 there. */ + const archivedChannels = new Set(); /** NIP-09 coordinates the relay accepted for deletion. */ const deleted = new Set(); const published: RelayEvent[] = []; @@ -241,6 +244,7 @@ export function archiveRelay( tags: [ ["d", id], ["name", id.slice(0, 8)], + ...(archivedChannels.has(id) ? [["archived", "true"]] : []), ], }), ); @@ -276,6 +280,10 @@ export function archiveRelay( } const tag = (name: string) => event.tags.find(([key]) => key === name)?.[1] ?? ""; + if (archivedChannels.has(tag("h"))) { + published.pop(); + throw new PublishRejected("invalid: channel is archived"); + } channels[tag("h")] = (channels[tag("h")] ?? []).filter( (key) => key !== tag("p"), ); @@ -317,6 +325,7 @@ export function archiveRelay( signedBy, script, channels, + archivedChannels, removal, deleted, }; From 016f65f86ec06dbd67c5bd6b3b2162d48399b534 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 10:30:57 -0400 Subject: [PATCH 2/3] Cover archived loaded channels in agent Delete Test the loaded-channel filter as well as the roster lookup, and say in the Delete flow docs that archived channels keep the agent. Co-authored-by: Oliver Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- docs/agents.md | 6 ++++-- src/features/agents/relay-removal.test.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 0e14d4efb9..2b45d013d3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -546,8 +546,10 @@ store and stops it, keeping its key. The cleanup (`AgentsService.cleanup`, share helpers in `features/agents/relay-removal.ts`) then runs: 1. An exact one-member 9001 (`h`, `p`, `client-id`) goes through the outbox for every - channel whose relay-signed 39002 roster lists the agent, plus the viewer's loaded - channels. Each channel's own fresh roster must then omit the agent. + unarchived channel whose relay-signed 39002 roster lists the agent, plus the + viewer's loaded unarchived channels. Each channel's own fresh roster must then omit + the agent. The relay refuses membership changes in an archived channel, so its + roster keeps the deleted agent. 2. A fresh archive read runs; unless it already lists the identity, it is archived through the request path above when a consent path exists. An agent that never published an owner-attested profile has nothing to hide and is not archived. diff --git a/src/features/agents/relay-removal.test.ts b/src/features/agents/relay-removal.test.ts index f466dbc964..fd0ff64ace 100644 --- a/src/features/agents/relay-removal.test.ts +++ b/src/features/agents/relay-removal.test.ts @@ -155,8 +155,15 @@ it("skips archived channels, where the relay refuses membership changes", async [archived]: [agent.pubkey, viewer.pubkey], }); fixture.archivedChannels.add(archived); + const loaded = [ + { id: active, members: [agent.pubkey] }, + { id: archived, archived: true, members: [agent.pubkey] }, + ]; await removeAgentFromChannels( - instance.session, + { + ...instance.session, + channels: { list: () => ({ channels: loaded }) }, + } as unknown as typeof instance.session, agent.pubkey, new AbortController().signal, ); From 4aaf8644cf64915bedf3d3b1d8bfa7380f5c937e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 11:32:52 -0400 Subject: [PATCH 3/3] Check loaded channels against fresh archive metadata A loaded channel row can still read as unarchived after another client archived it. Delete then sent a removal the relay refuses, and every retry failed the same way. memberChannels now applies the fresh relay archive check to the loaded candidates as well as to the relay's rosters. Co-authored-by: Oliver Co-Authored-By: Claude Opus 5.5 Signed-off-by: John Tennant --- src/features/agents/relay-removal.test.ts | 2 +- src/features/agents/relay-removal.ts | 19 ++++++++----------- src/features/relay/channel-writes.ts | 16 ++++++++++------ 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/features/agents/relay-removal.test.ts b/src/features/agents/relay-removal.test.ts index fd0ff64ace..4470f18917 100644 --- a/src/features/agents/relay-removal.test.ts +++ b/src/features/agents/relay-removal.test.ts @@ -157,7 +157,7 @@ it("skips archived channels, where the relay refuses membership changes", async fixture.archivedChannels.add(archived); const loaded = [ { id: active, members: [agent.pubkey] }, - { id: archived, archived: true, members: [agent.pubkey] }, + { id: archived, members: [agent.pubkey] }, ]; await removeAgentFromChannels( { diff --git a/src/features/agents/relay-removal.ts b/src/features/agents/relay-removal.ts index cadfe72d02..b355cf2493 100644 --- a/src/features/agents/relay-removal.ts +++ b/src/features/agents/relay-removal.ts @@ -159,16 +159,15 @@ export async function removeAgentFromChannels( pubkey: string, signal: AbortSignal, ) { - const pending = new Set([ - ...(await session.channelWrites.memberChannels(pubkey, signal)), - ...session.channels + const pending = await session.channelWrites.memberChannels( + pubkey, + signal, + session.channels .list() - .channels.filter( - (channel) => !channel.archived && channel.members?.includes(pubkey), - ) + .channels.filter((channel) => channel.members?.includes(pubkey)) .map((channel) => channel.id), - ]); - if (!pending.size) return; + ); + if (!pending.length) return; const outbox = session.outbox; if (!outbox?.supports(9001)) throw new Error("This community cannot remove agents from channels."); @@ -225,9 +224,7 @@ export async function removeAgentFromChannels( // Confirm each channel against its own fresh roster: a cached loaded roster // may still list the agent after the relay removed it. const listed = await Promise.all( - [...pending].map((id) => - session.channelWrites.listsMember(id, pubkey, signal), - ), + pending.map((id) => session.channelWrites.listsMember(id, pubkey, signal)), ); const remaining = listed.filter(Boolean); if (remaining.length) diff --git a/src/features/relay/channel-writes.ts b/src/features/relay/channel-writes.ts index c1e2e12f61..f942c4d832 100644 --- a/src/features/relay/channel-writes.ts +++ b/src/features/relay/channel-writes.ts @@ -342,11 +342,15 @@ export function createChannelWrites( ); return channel; } - /** Unarchived channel IDs whose relay-signed roster lists `pubkey`, as far - * as this viewer may read. The relay refuses membership changes in an - * archived channel, where a membership is inert. Throws rather than return a - * possibly truncated set. */ - async function memberChannels(pubkey: string, caller: AbortSignal) { + /** Unarchived channel IDs among `candidates` and those whose relay-signed + * roster lists `pubkey`, as far as this viewer may read. The relay refuses + * membership changes in an archived channel, where a membership is inert. + * Throws rather than return a possibly truncated set. */ + async function memberChannels( + pubkey: string, + caller: AbortSignal, + candidates: Iterable, + ) { if (!relayAuthor) throw new Error("Channel membership is unavailable."); const limit = 500; const events = await discovery.read( @@ -367,7 +371,7 @@ export function createChannelWrites( ); if (events.length >= limit) throw new Error("Too many channels to confirm. Retry later."); - const ids = new Set(); + const ids = new Set(candidates); for (const event of events) { const id = event.tags.find(([name]) => name === "d")?.[1]; if (