From 6296d1f481dceeb82805bd6222a3af8ea6829580 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Wed, 23 Sep 2026 18:49:45 -0700
Subject: [PATCH 1/7] feat: admit managed agents from profile channels
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
docs/profiles.md | 20 +-
src/bundled/channels/OutboxStatus.test.tsx | 51 +++++
src/bundled/channels/OutboxStatus.tsx | 8 +
.../profiles/ProfileAddChannel.test.tsx | 195 ++++++++++++++++++
src/bundled/profiles/ProfileAddChannel.tsx | 175 ++++++++++++++++
src/bundled/profiles/ProfileChannels.test.tsx | 12 ++
src/bundled/profiles/ProfileChannels.tsx | 15 ++
src/bundled/profiles/ProfilePanel.tsx | 2 +
src/features/relay/outbox-storage.ts | 5 +-
src/features/relay/outbox.test.ts | 119 +++++++++++
src/features/relay/outbox.ts | 57 +++--
.../relay/session-agent-admission.test.ts | 109 +++++++++-
src/features/relay/work-sessions.ts | 103 ++++++---
13 files changed, 823 insertions(+), 48 deletions(-)
create mode 100644 src/bundled/channels/OutboxStatus.test.tsx
create mode 100644 src/bundled/profiles/ProfileAddChannel.test.tsx
create mode 100644 src/bundled/profiles/ProfileAddChannel.tsx
diff --git a/docs/profiles.md b/docs/profiles.md
index 693f5c400..fdf2b43ad 100644
--- a/docs/profiles.md
+++ b/docs/profiles.md
@@ -9,6 +9,13 @@ activity** opens its raw panel for this exact identity and originating channel.
This action is offered for any public identity: it does not infer that the identity
is an owned/running agent. Missing telemetry is explained by the activity panel.
+Guarded invitations that fail or have an unknown outcome remain saved in the
+outbox. Its generic Retry action is withheld for these records: a renewed add
+through the channel composer or managed-agent profile rechecks current
+eligibility and reuses the exact saved event. Older unguarded invitation records
+are promoted to guarded intent when reused through this flow. Remove from
+outbox does not revoke an invitation already dispatched to the relay.
+
## Boundaries
- Shared message UI recognizes author-avatar targets and identity-bound mentions.
@@ -112,5 +119,14 @@ section stays hidden for a non-agent without a match. It never derives ownership
from the old Buzz library, self-declared profile markers, or names. The Agents
page route opens management, not a per-instance page.
-The Info tab keeps the public key and linked instances; the Channels tab is read-only.
-Neither list is a cross-community/global directory.
+The Info tab keeps the public key and linked instances. The Channels tab offers
+**Add to channel** only for an exact native-managed identity in this community
+that is also a managed session choice. It offers loaded, classified stream/forum
+channels with a roster, excluding archived, hidden, read-only and already-member
+rows. On submission it refreshes native evidence, then checks current agent,
+session and channel eligibility across the fresh roster read and at publisher
+entry. The relay still decides permission; local evidence does not grant it.
+Before publisher entry, navigation or loss of eligibility stops the write. Once
+publication begins, leaving the tab cannot undo the request; the session outbox
+retains its outcome and an unconfirmed result requires checking membership
+before attempting again. Neither list is a cross-community/global directory.
diff --git a/src/bundled/channels/OutboxStatus.test.tsx b/src/bundled/channels/OutboxStatus.test.tsx
new file mode 100644
index 000000000..bfa7c5fc7
--- /dev/null
+++ b/src/bundled/channels/OutboxStatus.test.tsx
@@ -0,0 +1,51 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { afterEach, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import type { Outbox, OutgoingEvent } from "../../features/relay/outbox";
+import { createRelayProfiler } from "../../features/relay/profiling";
+import { OutboxStatus } from "./OutboxStatus";
+
+afterEach(cleanup);
+const invitation = {
+ event: {
+ id: "invite",
+ kind: 9000,
+ content: "",
+ tags: [["p", "a".repeat(64)]],
+ },
+ delivery: "failed",
+} as OutgoingEvent;
+function show(item: OutgoingEvent) {
+ const retry = vi.fn();
+ const items = [item];
+ const outbox = {
+ snapshot: () => items,
+ subscribe: () => () => {},
+ retry,
+ dismiss: vi.fn(),
+ } as unknown as Outbox;
+ render( );
+ fireEvent.click(screen.getByText("Outbox · 1 items"));
+ return retry;
+}
+
+it("does not offer generic retry for guarded invitations from either profile or composer", () => {
+ const retry = show({ ...invitation, guarded: true });
+ expect(
+ screen.queryByRole("button", { name: "Retry" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/Retry from the channel or agent profile/),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "Remove from outbox" }),
+ ).toBeVisible();
+ expect(retry).not.toHaveBeenCalled();
+});
+
+it("preserves generic retry for legacy unguarded invitations", () => {
+ const retry = show(invitation);
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(retry).toHaveBeenCalledWith("invite");
+});
diff --git a/src/bundled/channels/OutboxStatus.tsx b/src/bundled/channels/OutboxStatus.tsx
index 51d6a2fdc..d8a5592db 100644
--- a/src/bundled/channels/OutboxStatus.tsx
+++ b/src/bundled/channels/OutboxStatus.tsx
@@ -49,6 +49,7 @@ export function OutboxStatus({
}
{" "}
{!isWorkflowOperation(item.event) &&
+ !item.guarded &&
(item.delivery === "failed" || item.delivery === "unknown") && (
)}{" "}
+ {item.guarded &&
+ (item.delivery === "failed" || item.delivery === "unknown") && (
+
+ Retry from the channel or agent profile after checking
+ membership.
+
+ )}{" "}
{item.delivery !== "sending" && (
void) | undefined;
+ let holdRefresh = false;
+ let currentRows = rows;
+ let currentNative = true;
+ const unavailable = { status: "error" };
+ let resolve!: () => void;
+ let reject!: (error: Error) => void;
+ const addAgents = vi.fn(
+ (_id: string, _keys: readonly string[], _active?: () => boolean) =>
+ new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ }),
+ );
+ const identities = { identities: [{ pubkey, managed }] };
+ const native = {
+ status: "ready",
+ data: { agents: [{ pubkey, relayUrl: "https://relay.example.test" }] },
+ };
+ const session = {
+ scope,
+ channels: { list: () => currentRows },
+ workSessions: { available: true, addAgents },
+ agentChoices: {
+ snapshot: () => identities,
+ subscribe: () => () => {},
+ },
+ } as unknown as RelaySession;
+ const control = {
+ snapshot: () => (currentNative ? native : unavailable),
+ subscribe: () => () => {},
+ refresh: vi.fn(() =>
+ holdRefresh
+ ? new Promise((resolve) => {
+ finishRefresh = resolve;
+ })
+ : Promise.resolve(),
+ ),
+ } as unknown as AgentControl;
+ return {
+ session,
+ control,
+ addAgents,
+ holdRefresh: () => {
+ holdRefresh = true;
+ },
+ releaseRefresh: () => finishRefresh?.(),
+ loseNative: () => {
+ currentNative = false;
+ },
+ switchScope: () => {
+ (session as { scope: string }).scope =
+ `https://other.example.test:${viewer}`;
+ },
+ hideChannel: () => {
+ currentRows = {
+ ...rows,
+ channels: rows.channels.map((row) =>
+ row.id === "one" ? { ...row, hidden: true } : row,
+ ),
+ };
+ },
+ resolve: () => resolve(),
+ reject: (message: string) => reject(new Error(message)),
+ };
+}
+function show(f = fixture()) {
+ render(
+ ,
+ );
+ return f;
+}
+afterEach(cleanup);
+it("offers only classified nonmember channels and waits for confirmed admission", async () => {
+ const f = show();
+ fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
+ const dialog = screen.getByRole("dialog");
+ const select = screen.getByRole("combobox", { name: "Channel" });
+ expect(select.textContent).toContain("#One");
+ expect(select.textContent).not.toMatch(/Joined|DM|Unknown/);
+ fireEvent.change(select, { target: { value: "one" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ expect(f.addAgents.mock.calls[0]?.slice(0, 2)).toEqual(["one", [pubkey]]);
+ expect(
+ screen.getByRole("button", { name: "Adding…" }).hasAttribute("disabled"),
+ ).toBe(true);
+ expect(dialog).toBeTruthy();
+ await act(async () => f.resolve());
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+});
+it("keeps the dialog open with a relay error and permits retry", async () => {
+ const f = show();
+ fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "one" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ await act(async () => f.reject("Membership denied"));
+ expect(screen.getByRole("alert").textContent).toBe("Membership denied");
+ fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledTimes(2));
+ await act(async () => f.resolve());
+});
+it("does not offer a write action without native and session agent evidence", () => {
+ show(fixture(false));
+ expect(screen.queryByRole("button", { name: /Add to channel/ })).toBeNull();
+});
+
+function submit() {
+ fireEvent.click(screen.getByRole("button", { name: /Add to channel/ }));
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "one" } });
+ fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
+}
+it.each(["native", "channel", "unmount"])(
+ "does not start admission when %s changes during native refresh",
+ async (change) => {
+ const f = fixture();
+ f.holdRefresh();
+ const view = render(
+ ,
+ );
+ submit();
+ await waitFor(() => expect(f.control.refresh).toHaveBeenCalledOnce());
+ if (change === "native") f.loseNative();
+ else if (change === "channel") f.hideChannel();
+ else view.unmount();
+ await act(async () => f.releaseRefresh());
+ expect(f.addAgents).not.toHaveBeenCalled();
+ },
+);
+it("passes current native, session, and channel eligibility into admission", async () => {
+ const f = show();
+ submit();
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
+ expect(active()).toBe(true);
+ f.hideChannel();
+ expect(active()).toBe(false);
+ await act(async () => f.resolve());
+});
+
+it("invalidates an in-flight gate after the session scope changes", async () => {
+ const f = show();
+ submit();
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
+ f.switchScope();
+ expect(active()).toBe(false);
+ await act(async () => f.resolve());
+});
diff --git a/src/bundled/profiles/ProfileAddChannel.tsx b/src/bundled/profiles/ProfileAddChannel.tsx
new file mode 100644
index 000000000..3cbe09feb
--- /dev/null
+++ b/src/bundled/profiles/ProfileAddChannel.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useRef, useState, useSyncExternalStore } from "react";
+import { sameCommunityAgents } from "../../features/agents/choices";
+import type { AgentControl } from "../../features/agents/control";
+import type { ChannelList } from "../../features/relay/contracts";
+import type { RelaySession } from "../../features/relay/session";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Dialog } from "../../shared/design-system/ui/Dialog";
+import { CaretRightIcon } from "../../shared/design-system/icons/index";
+
+/** Admission is available only for an exact native-managed agent in this community. */
+export function ProfileAddChannel({
+ session,
+ pubkey,
+ scope,
+ control,
+ list,
+}: {
+ session: RelaySession;
+ pubkey: string;
+ scope: string;
+ control: AgentControl;
+ list: ChannelList;
+}) {
+ useSyncExternalStore(control.subscribe, control.snapshot, control.snapshot);
+ useSyncExternalStore(
+ session.agentChoices.subscribe,
+ session.agentChoices.snapshot,
+ session.agentChoices.snapshot,
+ );
+ const mounted = useRef(true);
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+ const [open, setOpen] = useState(false);
+ const [selected, setSelected] = useState("");
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ const eligible = (id?: string) => {
+ const native = control.snapshot();
+ const list = session.channels.list();
+ const choices = session.agentChoices.snapshot();
+ return (
+ mounted.current &&
+ native.status === "ready" &&
+ sameCommunityAgents(native.data?.agents ?? [], scope).some(
+ (agent) => agent.pubkey === pubkey,
+ ) &&
+ session.scope === scope &&
+ session.workSessions.available &&
+ (list.status === "ready" || list.status === "error") &&
+ choices.identities.some(
+ (agent) => agent.pubkey === pubkey && agent.managed,
+ ) &&
+ (!id ||
+ !!list.channels.find(
+ (channel) =>
+ channel.id === id &&
+ !channel.archived &&
+ !channel.hidden &&
+ !channel.readOnly &&
+ (channel.channelType === "stream" ||
+ channel.channelType === "forum") &&
+ channel.members &&
+ !channel.members.includes(pubkey),
+ ))
+ );
+ };
+ if (!eligible()) return null;
+ const candidates = list.channels.filter((channel) => eligible(channel.id));
+ const selectedChannel = candidates.find((channel) => channel.id === selected);
+ return (
+ <>
+ {
+ setError("");
+ setSelected("");
+ setOpen(true);
+ }}
+ >
+ Add to channel
+
+
+
+ setOpen(false)}
+ >
+ Cancel
+
+ {
+ if (!selectedChannel || pending) return;
+ const id = selectedChannel.id;
+ const active = () => eligible(id);
+ setPending(true);
+ setError("");
+ void (async () => {
+ await control.refresh();
+ if (!active())
+ throw new DOMException(
+ "Channel addition cancelled",
+ "AbortError",
+ );
+ await session.workSessions.addAgents(id, [pubkey], active);
+ })()
+ .then(
+ () => {
+ if (mounted.current) {
+ setOpen(false);
+ setSelected("");
+ }
+ },
+ (reason: unknown) => {
+ if (mounted.current)
+ setError(
+ reason instanceof Error &&
+ reason.name !== "AbortError"
+ ? reason.message
+ : "Channel addition cancelled. Check membership before retrying.",
+ );
+ },
+ )
+ .finally(() => {
+ if (mounted.current) setPending(false);
+ });
+ }}
+ >
+ {pending ? "Adding…" : "Add to channel"}
+
+ >
+ }
+ >
+ Channel
+ {
+ setSelected(event.target.value);
+ setError("");
+ }}
+ >
+ Choose a channel
+ {candidates.map((channel) => (
+
+ #{channel.name}
+
+ ))}
+
+ {list.status !== "ready" && (
+
+ Channel discovery is incomplete; only loaded channels are offered.
+
+ )}
+ {!candidates.length && No eligible channels in the loaded list.
}
+ {error && {error}
}
+
+ >
+ );
+}
diff --git a/src/bundled/profiles/ProfileChannels.test.tsx b/src/bundled/profiles/ProfileChannels.test.tsx
index d938f1098..cf120a5d2 100644
--- a/src/bundled/profiles/ProfileChannels.test.tsx
+++ b/src/bundled/profiles/ProfileChannels.test.tsx
@@ -52,6 +52,8 @@ it("shows only exact verified visible memberships, handles partial lists and ope
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
expect(screen.getByRole("status").textContent).toContain("Loading channels");
@@ -119,6 +121,8 @@ it("does not invent a route, and retries failed discovery without claiming a com
viewer={viewer}
communityOrigin={undefined}
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
@@ -145,6 +149,8 @@ it("renders a verified row without a destination when navigation is unavailable"
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={undefined}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
@@ -167,6 +173,8 @@ it("keeps classified roster rows but omits unclassified conversations after meta
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
@@ -207,6 +215,8 @@ it("qualifies ready empty results when only matching unclassified memberships ex
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
@@ -271,6 +281,8 @@ it("does not show a metadata caveat for another identity's unclassified roster",
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
diff --git a/src/bundled/profiles/ProfileChannels.tsx b/src/bundled/profiles/ProfileChannels.tsx
index 05feab920..f1bd7a22c 100644
--- a/src/bundled/profiles/ProfileChannels.tsx
+++ b/src/bundled/profiles/ProfileChannels.tsx
@@ -1,3 +1,5 @@
+import type { AgentControl } from "../../features/agents/control";
+import { ProfileAddChannel } from "./ProfileAddChannel";
import type { Navigation } from "../../features/navigation/controller";
import { useChannelList } from "../../features/relay/react";
import type { RelaySession } from "../../features/relay/session";
@@ -12,12 +14,16 @@ export function ProfileChannels({
communityOrigin,
viewer,
navigation,
+ control,
+ scope,
}: {
session: RelaySession;
pubkey: string;
communityOrigin: string | undefined;
viewer: string | undefined;
navigation: Navigation | undefined;
+ control: AgentControl | undefined;
+ scope: string | undefined;
}) {
const list = useChannelList(session.channels);
const channels = (
@@ -41,6 +47,15 @@ export function ProfileChannels({
return (
+ {control && scope && (
+
+ )}
{(list.status === "idle" || list.status === "loading") && (
Loading channels…
diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx
index 60ad1916f..3594a48c0 100644
--- a/src/bundled/profiles/ProfilePanel.tsx
+++ b/src/bundled/profiles/ProfilePanel.tsx
@@ -247,6 +247,8 @@ function ProfileDetails({
navigation={navigation}
communityOrigin={communityOrigin}
viewer={viewer}
+ control={control}
+ scope={scope}
/>
)}
diff --git a/src/features/relay/outbox-storage.ts b/src/features/relay/outbox-storage.ts
index 16dd8aa1a..2e6e2536e 100644
--- a/src/features/relay/outbox-storage.ts
+++ b/src/features/relay/outbox-storage.ts
@@ -79,9 +79,10 @@ export function browserOutboxStorage(scope: string): OutboxStorage {
old &&
old.delivery === operation.delivery &&
old.error === operation.error &&
- old.signed?.id === operation.signed?.id &&
+old.signed?.id === operation.signed?.id &&
old.recovery?.key === operation.recovery?.key &&
- old.recovery?.value === operation.recovery?.value
+ old.recovery?.value === operation.recovery?.value &&
+ old.guarded === operation.guarded
)
continue;
events.put({ scope, id, operation });
diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts
index e380410f2..8c8d69d1b 100644
--- a/src/features/relay/outbox.test.ts
+++ b/src/features/relay/outbox.test.ts
@@ -864,3 +864,122 @@ it.each(["commit", "reject", "echo"] as const)(
}
},
);
+
+it("fences caller-scoped admission before signing and at publisher entry", async () => {
+ const h = setup();
+ const template = {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "c"],
+ ["p", "a".repeat(64)],
+ ],
+ };
+ let active = true;
+ const first = h.outbox.send(template, () => active);
+ active = false;
+ await vi.waitFor(() =>
+ expect(
+ h.outbox.snapshot().find((row) => row.event.id === first)?.delivery,
+ ).toBe("failed"),
+ );
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(() => h.outbox.retry(first)).toThrow(/cancelled/);
+ active = true;
+ h.outbox.retry(first, () => active);
+ await vi.waitFor(() => expect(h.sign).toHaveBeenCalledOnce());
+ const request = h.signatures.shift();
+ assert.exists(request);
+ active = false;
+ request.resolve(signed(viewer, request.event));
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed"),
+ );
+ expect(h.publish).not.toHaveBeenCalled();
+ active = true;
+ h.outbox.retry(first, () => active);
+ await vi.waitFor(() => expect(h.publish).toHaveBeenCalledOnce());
+ const dispatched = h.publications.shift();
+ assert.exists(dispatched);
+ active = false; // Past publisher entry: result may have reached the relay.
+ dispatched.resolve();
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("accepted"),
+ );
+});
+
+it("promotes a legacy saved invitation when a profile retries it with live admission", async () => {
+ const storage = memoryStorage();
+ const event = signed(viewer, {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "c"],
+ ["p", "a".repeat(64)],
+ ],
+ });
+ const { sig: _sig, ...unsigned } = event;
+ await storage.save([{ event: unsigned, delivery: "failed" }]);
+ const h = setup(storage);
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.event.id).toBe(event.id),
+ );
+ const id = event.id;
+ expect(h.outbox.snapshot()[0]?.guarded).toBeUndefined();
+
+ let active = true;
+ h.outbox.retry(id, () => active);
+ await vi.waitFor(() => expect(h.sign).toHaveBeenCalledOnce());
+ const request = h.signatures.shift();
+ assert.exists(request);
+ // A pre-hardening saved intent is now guarded, even while signing is held.
+ active = false;
+ request.resolve(signed(viewer, request.event));
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed"),
+ );
+ expect(h.outbox.snapshot()[0]?.guarded).toBe(true);
+ expect(h.publish).not.toHaveBeenCalled();
+ expect(() => h.outbox.retry(id)).toThrow(/cancelled/);
+
+ const restored = setup(storage);
+ await vi.waitFor(() =>
+ expect(restored.outbox.snapshot()[0]?.event.id).toBe(id),
+ );
+ expect(restored.outbox.snapshot()[0]?.guarded).toBe(true);
+ expect(() => restored.outbox.retry(id)).toThrow(/cancelled/);
+ expect(restored.publish).not.toHaveBeenCalled();
+});
+
+it("does not replay a guarded addition through generic retry or after hydration", async () => {
+ const storage = memoryStorage();
+ const h = setup(storage);
+ let active = true;
+ const id = h.outbox.send(
+ {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", "c"],
+ ["p", "a".repeat(64)],
+ ],
+ },
+ () => active,
+ );
+ active = false;
+ await vi.waitFor(() =>
+ expect(h.outbox.snapshot()[0]?.delivery).toBe("failed"),
+ );
+ expect(() => h.outbox.retry(id)).toThrow(/cancelled/);
+ const restored = setup(storage);
+ await vi.waitFor(() =>
+ expect(restored.outbox.snapshot()[0]?.event.id).toBe(id),
+ );
+ expect(restored.outbox.snapshot()[0]?.guarded).toBe(true);
+ expect(() => restored.outbox.retry(id)).toThrow(/cancelled/);
+ expect(restored.sign).not.toHaveBeenCalled();
+ expect(restored.publish).not.toHaveBeenCalled();
+ restored.outbox.retry(id, () => true); // Explicit renewed admission may reuse the exact event.
+ await vi.waitFor(() => expect(restored.sign).toHaveBeenCalledOnce());
+});
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 99bba7011..211938d3e 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -26,7 +26,8 @@ export type OutgoingEvent = Readonly<{
event: EventData;
signed?: RelayEvent;
recovery?: OutboxRecovery | undefined;
- delivery: Delivery;
+ /** A caller-scoped admission requires renewed live eligibility for retry. */
+ guarded?: boolean; delivery: Delivery;
error?: string | undefined;
}>;
export interface Outbox {
@@ -41,10 +42,10 @@ export interface Outbox {
send(
input: Pick,
recovery?: OutboxRecovery,
+ active?: () => boolean,
): string;
acknowledge(id: string): Promise;
- retry(id: string): void;
- dismiss(id: string): Promise;
+ retry(id: string, active?: () => boolean): void; dismiss(id: string): Promise;
}
type SendObserver = (
event: EventData,
@@ -93,6 +94,12 @@ export function createOutbox(
} = {},
) {
const awaitsReceipt = needsReceipt;
+ // Transient policy for a caller-scoped invitation. Restored writes never auto-replay.
+ const admissionGates = new Map boolean>();
+ const checkAdmission = (id: string) => {
+ if (find(id)?.guarded && admissionGates.get(id)?.() !== true)
+ throw new DOMException("Channel addition cancelled", "AbortError");
+ };
let snapshot: readonly OutgoingEvent[] = Object.freeze([]);
let visible: readonly OutgoingEvent[] = snapshot;
let finalSnapshot: readonly OutgoingEvent[] | undefined;
@@ -275,7 +282,7 @@ export function createOutbox(
}),
...(signed ? { signed } : {}),
...(item.recovery ? { recovery: recoveryValue(item.recovery) } : {}),
- delivery:
+ ...(item.guarded ? { guarded: true } : {}), delivery:
item.delivery === "seen" && signed
? "seen"
: item.delivery === "failed"
@@ -406,6 +413,7 @@ export function createOutbox(
if (storageError) throw new Error(storageError);
const initial = find(id);
if (!initial || closed || signal.aborted) return;
+ checkAdmission(id);
const signedResult =
initial.signed ??
(await profiling.measureAsync("send.sign", id, () =>
@@ -433,6 +441,7 @@ export function createOutbox(
signal.throwIfAborted();
const receipt = await profiling.measureAsync("send.publish", id, () => {
check?.();
+ checkAdmission(id);
publishing = true;
return Promise.race([writer.publish(signed, signal), aborted]);
});
@@ -485,6 +494,14 @@ export function createOutbox(
if (publishing && latest?.signed && !(error instanceof PublishRejected))
onAccepted(latest.signed);
} finally {
+ // Keep a failed invitation fenced for an explicit retry in this session.
+ if (
+ !find(id) ||
+ find(id)?.delivery === "accepted" ||
+ find(id)?.delivery === "seen"
+ ) {
+ admissionGates.delete(id);
+ }
total();
clearTimeout(attempt.timer);
if (attempts.get(id) === attempt) attempts.delete(id);
@@ -520,8 +537,7 @@ export function createOutbox(
supports: (kind: number) =>
!closed && (!writer.kinds || writer.kinds.includes(kind)),
async ready() {
- await ready;
- if (closed) throw abortError();
+ await ready; if (closed) throw abortError();
if (storageError) throw new Error(storageError);
},
async acknowledge(id: string) {
@@ -535,7 +551,10 @@ export function createOutbox(
send(
input: Pick,
recovery?: OutboxRecovery,
+ active?: () => boolean,
) {
+ if (active && !active())
+ throw new DOMException("Channel addition cancelled", "AbortError");
if (closed) throw abortError();
if (storageError) throw new Error(storageError);
const savedRecovery = recoveryValue(recovery);
@@ -577,6 +596,7 @@ export function createOutbox(
) as unknown as string[][],
id: getEventHash(template),
});
+ if (active) admissionGates.set(event.id, active);
captureSend(event);
profiling.measure("send.local", event.id, () => {
snapshot = Object.freeze([
@@ -585,8 +605,8 @@ export function createOutbox(
event,
delivery: "sending" as const,
...(savedRecovery ? { recovery: savedRecovery } : {}),
- }),
- ]);
+ ...(active ? { guarded: true } : {}),
+ }), ]);
notify();
});
const intent = ready.then(() => {
@@ -597,8 +617,10 @@ export function createOutbox(
schedule(event.id, intent);
return event.id;
},
- retry(id: string) {
+ retry(id: string, active?: () => boolean) {
const item = find(id);
+ if (item?.guarded && (active ?? admissionGates.get(id))?.() !== true)
+ throw new DOMException("Channel addition cancelled", "AbortError");
// Workflow recovery is inspect/dismiss only, including restored intents.
if (
!closed &&
@@ -607,9 +629,18 @@ export function createOutbox(
!attempts.has(id) &&
!dismissing.has(id)
) {
+ // A profile retry can reuse an older, unguarded saved invitation.
+ // Promote that intent before scheduling so signing and publication honor
+ // the renewed caller policy, including after the next hydration.
+ if (active) admissionGates.set(id, active);
if (item.delivery === "failed" || item.delivery === "unknown")
captureSend(item.event);
- replace({ ...item, delivery: "sending", error: undefined });
+ replace({
+ ...item,
+ ...(active ? { guarded: true } : {}),
+ delivery: "sending",
+ error: undefined,
+ });
schedule(id, undefined, item.delivery);
}
},
@@ -621,8 +652,9 @@ export function createOutbox(
return Promise.reject(
new Error("Confirm this message in New message before removing it"),
);
- const work = persist(id, "dismiss").finally(() => dismissing.delete(id));
- dismissing.set(id, work);
+ const work = persist(id, "dismiss").then(() => {
+ admissionGates.delete(id);
+ }).finally(() => dismissing.delete(id)); dismissing.set(id, work);
return work;
},
});
@@ -702,6 +734,7 @@ export function createOutbox(
finalSnapshot = snapshot;
finalVisible = visible;
closed = true;
+ admissionGates.clear();
lifetime.abort();
for (const attempt of attempts.values()) {
attempt.controller?.abort(abortError());
diff --git a/src/features/relay/session-agent-admission.test.ts b/src/features/relay/session-agent-admission.test.ts
index 39c764738..f7425f554 100644
--- a/src/features/relay/session-agent-admission.test.ts
+++ b/src/features/relay/session-agent-admission.test.ts
@@ -20,6 +20,22 @@ function setup() {
let foreignRoster = false;
let libraryFailure = false;
let afterPublish = () => {};
+ let omitMembership = false;
+ let holdRoster: Promise | undefined;
+ let releaseRoster: (() => void) | undefined;
+ let rosterStarted: (() => void) | undefined;
+ let rosterRequested: Promise | undefined;
+ let holdSigning: Promise | undefined;
+ let releaseSigning: (() => void) | undefined;
+ let signingStarted: (() => void) | undefined;
+ let signingRequested: Promise | undefined;
+ const sign = vi.fn(async (template: Parameters[1]) => {
+ if (template.kind === 9000 && holdSigning) {
+ signingStarted?.();
+ await holdSigning;
+ }
+ return signed(viewer, template);
+ });
const secondAgent = keypair().pubkey;
const meta = (id: string, type: string, extra: string[][] = []) =>
signed(relay, {
@@ -31,8 +47,10 @@ function setup() {
const target = _event.tags.find(([name]) => name === "h")?.[1];
if (denied || (denyChild && target === child))
throw new PublishRejected("Only channel admins can add agents");
- if (target === parent) members = [viewer.pubkey, agent.pubkey];
- if (target === child) childMembers = [viewer.pubkey, agent.pubkey];
+ if (!omitMembership && target === parent)
+ members = [viewer.pubkey, agent.pubkey];
+ if (!omitMembership && target === child)
+ childMembers = [viewer.pubkey, agent.pubkey];
clock++;
afterPublish();
});
@@ -53,10 +71,20 @@ function setup() {
},
writer: {
kinds: [9, 9000, 9007],
- sign: async (template) => signed(viewer, template),
+ sign,
publish,
},
query: async (filters) => {
+ if (
+ holdRoster &&
+ filters.some(
+ (filter) =>
+ filter.kinds?.includes(39002) && filter["#d"]?.includes(parent),
+ )
+ ) {
+ rosterStarted?.();
+ await holdRoster;
+ }
if (foreignRoster && filters.some((filter) => filter["#d"]))
return [
roster(agent, child, [viewer.pubkey, agent.pubkey], clock + 1),
@@ -104,6 +132,30 @@ function setup() {
afterPublish = callback;
},
publish,
+ sign,
+ omitMembership: () => {
+ omitMembership = true;
+ },
+ holdRoster: () => {
+ rosterRequested = new Promise((resolve) => {
+ rosterStarted = resolve;
+ });
+ holdRoster = new Promise((resolve) => {
+ releaseRoster = resolve;
+ });
+ return rosterRequested;
+ },
+ releaseRoster: () => releaseRoster?.(),
+ holdSigning: () => {
+ signingRequested = new Promise((resolve) => {
+ signingStarted = resolve;
+ });
+ holdSigning = new Promise((resolve) => {
+ releaseSigning = resolve;
+ });
+ return signingRequested;
+ },
+ releaseSigning: () => releaseSigning?.(),
denyChild: (value: boolean) => {
denyChild = value;
},
@@ -154,6 +206,27 @@ it("adds an outside agent once to each channel and confirms both memberships", a
test.owner.dispose();
}
});
+it("reports channel-specific uncertainty when an accepted addition lacks roster confirmation", async () => {
+ const test = setup();
+ try {
+ await test.ready();
+ test.omitMembership();
+ vi.useFakeTimers();
+ const adding = test.owner.session.workSessions.addAgents(test.parent, [
+ test.agent,
+ ]);
+ const result = expect(adding).rejects.toThrow(
+ /Agent addition is unconfirmed.*Check channel membership/,
+ );
+ await vi.waitFor(() => expect(test.publish).toHaveBeenCalledOnce());
+ await vi.advanceTimersByTimeAsync(15000);
+ await result;
+ } finally {
+ vi.useRealTimers();
+ test.owner.dispose();
+ }
+});
+
it("keeps channel permission failures and retries the same saved invitation", async () => {
const test = setup();
try {
@@ -347,3 +420,33 @@ it("does not accept a foreign-signed roster as fresh membership", async () => {
test.owner.dispose();
}
});
+
+it.each(["roster", "signing"])(
+ "stops admission after native evidence disappears at the %s boundary",
+ async (boundary) => {
+ const test = setup();
+ let active = true;
+ try {
+ await test.ready();
+ const started =
+ boundary === "roster" ? test.holdRoster() : test.holdSigning();
+ const admission = test.owner.session.workSessions.addAgents(
+ test.parent,
+ [test.agent],
+ () => active,
+ );
+ await started;
+ active = false; // Legacy choice remains available to the broad session API.
+ if (boundary === "roster") test.releaseRoster();
+ else test.releaseSigning();
+ await expect(admission).rejects.toThrow(/cancelled/);
+ expect(test.publish).not.toHaveBeenCalled();
+ if (boundary === "roster") expect(test.sign).not.toHaveBeenCalled();
+ else expect(test.sign).toHaveBeenCalledOnce();
+ } finally {
+ test.releaseRoster();
+ test.releaseSigning();
+ test.owner.dispose();
+ }
+ },
+);
diff --git a/src/features/relay/work-sessions.ts b/src/features/relay/work-sessions.ts
index dd72b99c9..e9a209652 100644
--- a/src/features/relay/work-sessions.ts
+++ b/src/features/relay/work-sessions.ts
@@ -19,24 +19,41 @@ export function createWorkSessions(
relayAuthor?: string,
) {
const available = !!outbox?.supports(9007);
- function writer() {
+ function writer(addition = false) {
if (signal.aborted || !available || !outbox)
throw new Error(
- "This community does not support channel creation yet. Your draft is kept here.",
+ addition
+ ? "This community cannot add agents to channels right now. Check the connection and retry."
+ : "This community does not support channel creation yet. Your draft is kept here.",
);
return outbox;
}
function identifier(id: string) {
if (!uuid.test(id)) throw new Error("Invalid session identifier");
}
- async function delivered(id: string) {
- const source = writer();
+ async function delivered(
+ id: string,
+ active?: () => boolean,
+ addition = false,
+ ) {
+ const check = () => {
+ if (active?.() === false)
+ throw new DOMException("Channel addition cancelled", "AbortError");
+ };
+ check();
+ const source = writer(addition);
const journal = receipts ?? source;
const existing = journal.snapshot().find((item) => item.event.id === id);
if (!existing) {
- if (await confirmCreation?.(id)) return;
+ if (await confirmCreation?.(id)) {
+ check();
+ return;
+ }
const events = await reader.read([{ ids: [id], limit: 1 }], { signal });
- if (events.some((event) => event.id === id)) return;
+ if (events.some((event) => event.id === id)) {
+ check();
+ return;
+ }
throw new Error(
"The saved operation could not be confirmed. Reconnect and retry.",
);
@@ -45,7 +62,10 @@ export function createWorkSessions(
// Ordinary channel creation rejects a repeated channel UUID. An exact,
// verified creation event can confirm an earlier lost acknowledgment.
if (existing.event.kind === 9007) {
- if (await confirmCreation?.(id)) return;
+ if (await confirmCreation?.(id)) {
+ check();
+ return;
+ }
const channelId = existing.event.tags.find(
([name]) => name === "h",
)?.[1];
@@ -65,10 +85,14 @@ export function createWorkSessions(
(event) =>
event.id === id && event.pubkey === existing.event.pubkey,
)
- )
+ ) {
+ check();
return;
+ }
}
- source.retry(id);
+ check();
+ if (active) source.retry(id, active);
+ else source.retry(id);
}
await new Promise((resolve, reject) => {
let unsubscribe = () => {};
@@ -80,13 +104,19 @@ export function createWorkSessions(
};
const aborted = () =>
finish(
- new Error("The community connection changed. Your draft is kept."),
+ new Error(
+ addition
+ ? "The community connection changed. Check channel membership before adding again."
+ : "The community connection changed. Your draft is kept.",
+ ),
);
const timer = setTimeout(
() =>
finish(
new Error(
- "Still waiting for confirmation. Retry without starting another session.",
+ addition
+ ? "Agent addition is unconfirmed. Check channel membership before retrying; the request may already have reached the relay."
+ : "Still waiting for confirmation. Retry without starting another session.",
),
),
15000,
@@ -97,7 +127,12 @@ export function createWorkSessions(
finish();
else if (item?.delivery === "failed" || item?.delivery === "unknown")
finish(
- new Error(item.error ?? "The operation could not be confirmed."),
+ new Error(
+ item.error ??
+ (addition
+ ? "Agent addition could not be confirmed. Check membership before retrying."
+ : "The operation could not be confirmed."),
+ ),
);
};
unsubscribe = journal.subscribe(inspect);
@@ -105,6 +140,7 @@ export function createWorkSessions(
if (signal.aborted) aborted();
else inspect();
});
+ check();
}
async function refresh(
id: string,
@@ -115,7 +151,7 @@ export function createWorkSessions(
} = {},
sessionOnly = true,
) {
- writer();
+ writer(!sessionOnly);
const wait = new Promise((resolve, reject) => {
let unsubscribe = () => {};
const done = (error?: Error) => {
@@ -129,7 +165,9 @@ export function createWorkSessions(
() =>
done(
new Error(
- "Session saved, but its membership is still loading. Retry to continue.",
+ sessionOnly
+ ? "Session saved, but its membership is still loading. Retry to continue."
+ : "Agent addition is unconfirmed. Check channel membership before retrying; the request may already have reached the relay.",
),
),
15000,
@@ -189,12 +227,14 @@ export function createWorkSessions(
async function addAgents(
id: string,
keys: readonly string[],
- active = () => true,
+ active?: () => boolean,
) {
const find = () => channels.list().channels.find((item) => item.id === id);
+ if (active?.() === false)
+ throw new DOMException("Channel addition cancelled", "AbortError");
const original = await refreshMembership(id);
- if (!active())
- throw new DOMException("Session submission cancelled", "AbortError");
+ if (active?.() === false)
+ throw new DOMException("Channel addition cancelled", "AbortError");
const unique = [...new Set(keys)].filter(
(key) =>
original.channelType !== "session" || !original.members?.includes(key),
@@ -217,9 +257,9 @@ export function createWorkSessions(
const targets = parentId !== id ? [parentId, id] : [parentId];
for (const targetId of targets) {
for (const key of unique) {
- if (!active())
- throw new DOMException("Session submission cancelled", "AbortError");
- writer();
+ if (active?.() === false)
+ throw new DOMException("Channel addition cancelled", "AbortError");
+ writer(!!active);
if (find()?.parentChannelId !== original?.parentChannelId)
throw new Error("This session moved. Refresh and retry.");
const current = channels
@@ -244,15 +284,20 @@ export function createWorkSessions(
);
const operation =
previous?.event.id ??
- writer().send({
- kind: 9000,
- content: "",
- tags: [
- ["h", targetId],
- ["p", key],
- ],
- });
- await delivered(operation);
+ writer(!!active).send(
+ {
+ kind: 9000,
+ content: "",
+ tags: [
+ ["h", targetId],
+ ["p", key],
+ ],
+ },
+ active,
+ );
+ await delivered(operation, active, true);
+ if (active?.() === false)
+ throw new DOMException("Channel addition cancelled", "AbortError");
await refresh(targetId, { member: key }, false);
}
}
From 26ae4af8326b153585716da5b8213e1e4090b477 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Wed, 23 Sep 2026 18:49:54 -0700
Subject: [PATCH 2/7] docs: describe profile admission and retain channel
assertions
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
docs/profiles.md | 5 +++--
src/bundled/profiles/ProfileChannels.test.tsx | 8 ++++----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/docs/profiles.md b/docs/profiles.md
index fdf2b43ad..535586bd9 100644
--- a/docs/profiles.md
+++ b/docs/profiles.md
@@ -1,6 +1,6 @@
# Profiles: viewing public identities
-The bundled `buzz.profiles` plugin supplies a minimal, read-only panel for any
+The bundled `buzz.profiles` plugin supplies a profile panel for any
public identity, human or agent. It uses the current session's shared profile
directory. Agents retains agent-specific configuration/operations; this slice
adds no ownership/running badge, editor, agent-library read or execution API.
@@ -95,7 +95,8 @@ heads: `live-session.test.ts` forces overlapping unread reads for 1, 2 and 130
channels; `sidebar-unread.spec.mjs` holds unread evidence through real EOSE and
checks its badges without retries. Access-loss/disconnect cancellation is unchanged.
Broad scan and native build/package acceptance remain deferred to an agreed
-integration batch. No sending/signing behavior changed.
+integration batch. The earlier read-only profile slice changed no sending/signing
+behavior; the managed-agent admission described below does.
## Info, channels and linked instances
diff --git a/src/bundled/profiles/ProfileChannels.test.tsx b/src/bundled/profiles/ProfileChannels.test.tsx
index cf120a5d2..cc88fcfb1 100644
--- a/src/bundled/profiles/ProfileChannels.test.tsx
+++ b/src/bundled/profiles/ProfileChannels.test.tsx
@@ -90,8 +90,8 @@ it("shows only exact verified visible memberships, handles partial lists and ope
{ id: "archived", name: "Archived", archived: true, members: [person] },
],
});
- expect(screen.getByRole("button", { name: "#Visible" })).toBeTruthy();
- expect(screen.getByRole("button", { name: "#Forum" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /#Visible/ })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /#Forum/ })).toBeTruthy();
expect(
screen.getByText(/Some memberships for this identity are unclassified/),
).toBeTruthy();
@@ -99,7 +99,7 @@ it("shows only exact verified visible memberships, handles partial lists and ope
screen.queryByText(/Unrelated|Unknown type|Hidden|Archived|Direct|Child/),
).toBeNull();
expect(screen.getByText(/More channels may exist/)).toBeTruthy();
- fireEvent.click(screen.getByRole("button", { name: "#Visible" }));
+ fireEvent.click(screen.getByRole("button", { name: /#Visible/ }));
expect(f.open).toHaveBeenCalledWith({
version: 1,
kind: "conversation",
@@ -199,7 +199,7 @@ it("keeps classified roster rows but omits unclassified conversations after meta
],
});
expect(screen.getByRole("alert")).toBeTruthy();
- expect(screen.getByRole("button", { name: "#Known" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /#Known/ })).toBeTruthy();
expect(
screen.getByText(/Some memberships for this identity are unclassified/),
).toBeTruthy();
From e4078036b0ca9448c17e7d3c592ff78644582ae2 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Wed, 23 Sep 2026 19:48:16 -0700
Subject: [PATCH 3/7] fix: retain profile admission on roster update and reject
expired invitations
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
docs/profiles.md | 8 +++---
.../profiles/ProfileAddChannel.test.tsx | 19 +++++++++++++
src/bundled/profiles/ProfileAddChannel.tsx | 7 ++---
.../relay/session-agent-admission.test.ts | 27 +++++++++++++++++++
src/features/relay/work-sessions.ts | 7 +++++
5 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/docs/profiles.md b/docs/profiles.md
index 535586bd9..119ca6acf 100644
--- a/docs/profiles.md
+++ b/docs/profiles.md
@@ -12,9 +12,11 @@ is an owned/running agent. Missing telemetry is explained by the activity panel.
Guarded invitations that fail or have an unknown outcome remain saved in the
outbox. Its generic Retry action is withheld for these records: a renewed add
through the channel composer or managed-agent profile rechecks current
-eligibility and reuses the exact saved event. Older unguarded invitation records
-are promoted to guarded intent when reused through this flow. Remove from
-outbox does not revoke an invitation already dispatched to the relay.
+eligibility and reuses the exact saved event while it is still within the relay's
+15-minute timestamp window. After that, check membership; if absent, remove the
+expired “Add agent” item from Outbox and add the agent again. Older unguarded
+invitation records are promoted to guarded intent when reused through this flow.
+Remove from outbox does not revoke an invitation already dispatched to the relay.
## Boundaries
diff --git a/src/bundled/profiles/ProfileAddChannel.test.tsx b/src/bundled/profiles/ProfileAddChannel.test.tsx
index 0ff96978b..7eefa730c 100644
--- a/src/bundled/profiles/ProfileAddChannel.test.tsx
+++ b/src/bundled/profiles/ProfileAddChannel.test.tsx
@@ -93,6 +93,14 @@ function fixture(managed = true) {
),
};
},
+ addMembership: () => {
+ currentRows = {
+ ...rows,
+ channels: rows.channels.map((row) =>
+ row.id === "one" ? { ...row, members: [viewer, pubkey] } : row,
+ ),
+ };
+ },
resolve: () => resolve(),
reject: (message: string) => reject(new Error(message)),
};
@@ -184,6 +192,17 @@ it("passes current native, session, and channel eligibility into admission", asy
await act(async () => f.resolve());
});
+it("keeps admission active when its own roster update adds the agent", async () => {
+ const f = show();
+ submit();
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ const active = f.addAgents.mock.calls[0]?.[2] as unknown as () => boolean;
+ f.addMembership();
+ expect(active()).toBe(true);
+ await act(async () => f.resolve());
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+});
+
it("invalidates an in-flight gate after the session scope changes", async () => {
const f = show();
submit();
diff --git a/src/bundled/profiles/ProfileAddChannel.tsx b/src/bundled/profiles/ProfileAddChannel.tsx
index 3cbe09feb..f4d140c38 100644
--- a/src/bundled/profiles/ProfileAddChannel.tsx
+++ b/src/bundled/profiles/ProfileAddChannel.tsx
@@ -38,7 +38,7 @@ export function ProfileAddChannel({
const [selected, setSelected] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
- const eligible = (id?: string) => {
+ const eligible = (id?: string, requireNonmember = true) => {
const native = control.snapshot();
const list = session.channels.list();
const choices = session.agentChoices.snapshot();
@@ -64,7 +64,7 @@ export function ProfileAddChannel({
(channel.channelType === "stream" ||
channel.channelType === "forum") &&
channel.members &&
- !channel.members.includes(pubkey),
+ (!requireNonmember || !channel.members.includes(pubkey)),
))
);
};
@@ -105,7 +105,8 @@ export function ProfileAddChannel({
onClick={() => {
if (!selectedChannel || pending) return;
const id = selectedChannel.id;
- const active = () => eligible(id);
+ // Membership becoming true after submission is success, not revocation.
+ const active = () => eligible(id, false);
setPending(true);
setError("");
void (async () => {
diff --git a/src/features/relay/session-agent-admission.test.ts b/src/features/relay/session-agent-admission.test.ts
index f7425f554..c6be6348f 100644
--- a/src/features/relay/session-agent-admission.test.ts
+++ b/src/features/relay/session-agent-admission.test.ts
@@ -249,6 +249,33 @@ it("keeps channel permission failures and retries the same saved invitation", as
test.owner.dispose();
}
});
+it("explains how to recover an expired failed invitation without republishing it", async () => {
+ const test = setup();
+ try {
+ await test.ready();
+ test.setDenied(true);
+ const now = Date.now;
+ vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
+ try {
+ await expect(
+ test.owner.session.workSessions.addAgents(test.parent, [test.agent]),
+ ).rejects.toThrow(/Only channel admins/);
+ expect(test.publish).toHaveBeenCalledOnce();
+ vi.mocked(Date.now).mockReturnValue(1_700_000_901_000);
+ test.setDenied(false);
+ await expect(
+ test.owner.session.workSessions.addAgents(test.parent, [test.agent]),
+ ).rejects.toThrow(/expired.*Outbox.*remove.*add the agent again/i);
+ expect(test.publish).toHaveBeenCalledOnce();
+ } finally {
+ vi.restoreAllMocks();
+ expect(Date.now).toBe(now);
+ }
+ } finally {
+ test.owner.dispose();
+ }
+});
+
it("rejects nonmember identities outside the agent library before adding anyone", async () => {
const test = setup();
try {
diff --git a/src/features/relay/work-sessions.ts b/src/features/relay/work-sessions.ts
index e9a209652..8de9b028a 100644
--- a/src/features/relay/work-sessions.ts
+++ b/src/features/relay/work-sessions.ts
@@ -282,6 +282,13 @@ export function createWorkSessions(
) &&
!item.event.tags.some(([name]) => name === "role"),
);
+ if (
+ previous &&
+ Date.now() / 1000 - previous.event.created_at >= 15 * 60
+ )
+ throw new Error(
+ `This agent-add request expired. Open Outbox, remove the “Add agent ${key.slice(0, 12)}” item, then add the agent again. Check channel membership first if the request was unconfirmed.`,
+ );
const operation =
previous?.event.id ??
writer(!!active).send(
From d077a3e3597056ddf98bc14fc28b94db919f8df8 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Wed, 23 Sep 2026 19:52:10 -0700
Subject: [PATCH 4/7] test: supply stacked profile eligibility props in
exclusion case
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
src/bundled/profiles/ProfileChannels.test.tsx | 2 ++
src/features/relay/outbox.ts | 9 ++++++---
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/src/bundled/profiles/ProfileChannels.test.tsx b/src/bundled/profiles/ProfileChannels.test.tsx
index cc88fcfb1..3fc093105 100644
--- a/src/bundled/profiles/ProfileChannels.test.tsx
+++ b/src/bundled/profiles/ProfileChannels.test.tsx
@@ -252,6 +252,8 @@ it("does not classify hidden, archived or DM memberships in a ready empty list",
viewer={viewer}
communityOrigin="https://relay.example.test"
navigation={f.navigation}
+ control={undefined}
+ scope={undefined}
/>,
);
f.update({
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 211938d3e..2af09b43c 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -652,9 +652,12 @@ export function createOutbox(
return Promise.reject(
new Error("Confirm this message in New message before removing it"),
);
- const work = persist(id, "dismiss").then(() => {
- admissionGates.delete(id);
- }).finally(() => dismissing.delete(id)); dismissing.set(id, work);
+ const work = persist(id, "dismiss")
+ .then(() => {
+ admissionGates.delete(id);
+ })
+ .finally(() => dismissing.delete(id));
+ dismissing.set(id, work);
return work;
},
});
From 9426ed4c733c05714ec157724f59e6706cc5a105 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Wed, 23 Sep 2026 22:34:47 -0700
Subject: [PATCH 5/7] fix: retain profile admission recovery after native
refresh failure
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
.../profiles/ProfileAddChannel.test.tsx | 53 +++++++++++++-
src/bundled/profiles/ProfileAddChannel.tsx | 70 ++++++++++++++-----
2 files changed, 101 insertions(+), 22 deletions(-)
diff --git a/src/bundled/profiles/ProfileAddChannel.test.tsx b/src/bundled/profiles/ProfileAddChannel.test.tsx
index 7eefa730c..502fa8127 100644
--- a/src/bundled/profiles/ProfileAddChannel.test.tsx
+++ b/src/bundled/profiles/ProfileAddChannel.test.tsx
@@ -46,10 +46,15 @@ function fixture(managed = true) {
}),
);
const identities = { identities: [{ pubkey, managed }] };
- const native = {
+ let native = {
status: "ready",
data: { agents: [{ pubkey, relayUrl: "https://relay.example.test" }] },
};
+ const nativeListeners = new Set<() => void>();
+ const updateNative = (status: "ready" | "error") => {
+ native = { ...native, status };
+ for (const listener of nativeListeners) listener();
+ };
const session = {
scope,
channels: { list: () => currentRows },
@@ -61,7 +66,10 @@ function fixture(managed = true) {
} as unknown as RelaySession;
const control = {
snapshot: () => (currentNative ? native : unavailable),
- subscribe: () => () => {},
+ subscribe: (listener: () => void) => {
+ nativeListeners.add(listener);
+ return () => nativeListeners.delete(listener);
+ },
refresh: vi.fn(() =>
holdRefresh
? new Promise((resolve) => {
@@ -77,10 +85,14 @@ function fixture(managed = true) {
holdRefresh: () => {
holdRefresh = true;
},
- releaseRefresh: () => finishRefresh?.(),
+ releaseRefresh: () => {
+ holdRefresh = false;
+ finishRefresh?.();
+ },
loseNative: () => {
currentNative = false;
},
+ updateNative,
switchScope: () => {
(session as { scope: string }).scope =
`https://other.example.test:${viewer}`;
@@ -212,3 +224,38 @@ it("invalidates an in-flight gate after the session scope changes", async () =>
expect(active()).toBe(false);
await act(async () => f.resolve());
});
+
+it("keeps the operation visible after a notifying native refresh failure and retries safely", async () => {
+ const f = fixture();
+ f.holdRefresh();
+ show(f);
+ submit();
+ await waitFor(() => expect(f.control.refresh).toHaveBeenCalledOnce());
+ await act(async () => f.updateNative("error"));
+ const dialog = screen.getByRole("dialog", { name: "Add agent to channel" });
+ expect(dialog).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Adding…" }).hasAttribute("disabled"),
+ ).toBe(true);
+ await act(async () => f.releaseRefresh());
+ expect(f.addAgents).not.toHaveBeenCalled();
+ expect(screen.getByText(/Channel addition cancelled/)).toBeTruthy();
+ expect(
+ screen
+ .getByRole("button", { name: "Add to channel" })
+ .hasAttribute("disabled"),
+ ).toBe(true);
+ fireEvent.click(screen.getByRole("button", { name: "Retry agents" }));
+ expect(f.control.refresh).toHaveBeenCalledTimes(2);
+ await act(async () => f.updateNative("ready"));
+ expect(dialog).toBeTruthy();
+ expect(
+ screen
+ .getByRole("button", { name: "Add to channel" })
+ .hasAttribute("disabled"),
+ ).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Add to channel" }));
+ await waitFor(() => expect(f.addAgents).toHaveBeenCalledOnce());
+ await act(async () => f.resolve());
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+});
diff --git a/src/bundled/profiles/ProfileAddChannel.tsx b/src/bundled/profiles/ProfileAddChannel.tsx
index f4d140c38..31595de32 100644
--- a/src/bundled/profiles/ProfileAddChannel.tsx
+++ b/src/bundled/profiles/ProfileAddChannel.tsx
@@ -21,7 +21,11 @@ export function ProfileAddChannel({
control: AgentControl;
list: ChannelList;
}) {
- useSyncExternalStore(control.subscribe, control.snapshot, control.snapshot);
+ const nativeState = useSyncExternalStore(
+ control.subscribe,
+ control.snapshot,
+ control.snapshot,
+ );
useSyncExternalStore(
session.agentChoices.subscribe,
session.agentChoices.snapshot,
@@ -68,23 +72,29 @@ export function ProfileAddChannel({
))
);
};
- if (!eligible()) return null;
- const candidates = list.channels.filter((channel) => eligible(channel.id));
+ const available = eligible();
+ if (!available && !open) return null;
+ const candidates = available
+ ? list.channels.filter((channel) => eligible(channel.id))
+ : [];
const selectedChannel = candidates.find((channel) => channel.id === selected);
return (
<>
- {
- setError("");
- setSelected("");
- setOpen(true);
- }}
- >
- Add to channel
-
-
+ {(available || open) && (
+ {
+ setError("");
+ setSelected("");
+ setOpen(true);
+ }}
+ >
+ Add to channel
+
+
+ )}
{
- if (!selectedChannel || pending) return;
+ if (!selectedChannel || pending || !available) return;
const id = selectedChannel.id;
// Membership becoming true after submission is success, not revocation.
const active = () => eligible(id, false);
@@ -150,7 +160,7 @@ export function ProfileAddChannel({
id="profile-add-channel"
className="buzz-input"
value={selected}
- disabled={pending}
+ disabled={pending || !available}
onChange={(event) => {
setSelected(event.target.value);
setError("");
@@ -168,7 +178,29 @@ export function ProfileAddChannel({
Channel discovery is incomplete; only loaded channels are offered.
)}
- {!candidates.length && No eligible channels in the loaded list.
}
+ {nativeState.status === "error" && (
+
+
+ Could not refresh local agents. Retry to get current host status.
+
+
void control.refresh()}
+ >
+ Retry agents
+
+
+ )}
+ {!available && nativeState.status !== "error" && !pending && (
+
+ This agent or channel is no longer eligible. Check its status before
+ adding again.
+
+ )}
+ {!candidates.length && available && (
+ No eligible channels in the loaded list.
+ )}
{error && {error}
}
>
From dabf31af1c9ada906cb657533b56ae6b3f3cee3f Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Thu, 24 Sep 2026 07:26:40 -0700
Subject: [PATCH 6/7] fix: reconcile profile admission with durable outbox
recovery
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
docs/profiles.md | 14 ++++++++------
src/features/relay/outbox-storage.ts | 2 +-
src/features/relay/outbox.test.ts | 3 ++-
src/features/relay/outbox.ts | 15 ++++++++++-----
src/features/relay/work-sessions.ts | 1 +
5 files changed, 22 insertions(+), 13 deletions(-)
diff --git a/docs/profiles.md b/docs/profiles.md
index 119ca6acf..cd36d449a 100644
--- a/docs/profiles.md
+++ b/docs/profiles.md
@@ -10,12 +10,14 @@ This action is offered for any public identity: it does not infer that the ident
is an owned/running agent. Missing telemetry is explained by the activity panel.
Guarded invitations that fail or have an unknown outcome remain saved in the
-outbox. Its generic Retry action is withheld for these records: a renewed add
-through the channel composer or managed-agent profile rechecks current
-eligibility and reuses the exact saved event while it is still within the relay's
-15-minute timestamp window. After that, check membership; if absent, remove the
-expired “Add agent” item from Outbox and add the agent again. Older unguarded
-invitation records are promoted to guarded intent when reused through this flow.
+outbox. Its generic Retry action is withheld for these records. Retrying the
+add from the managed-agent profile rechecks current eligibility and reuses the
+exact saved event while it is still within the relay's 15-minute timestamp
+window. The ordinary channel composer uses a separate invitation path; it does
+not renew or reuse these guarded profile invitations. After expiry, check
+membership; if absent, remove the expired “Add agent” item from Outbox and
+add the agent again from the managed-agent profile. Older unguarded invitation
+records can be promoted to guarded intent when reused through that profile flow.
Remove from outbox does not revoke an invitation already dispatched to the relay.
## Boundaries
diff --git a/src/features/relay/outbox-storage.ts b/src/features/relay/outbox-storage.ts
index 2e6e2536e..74ba7523c 100644
--- a/src/features/relay/outbox-storage.ts
+++ b/src/features/relay/outbox-storage.ts
@@ -79,7 +79,7 @@ export function browserOutboxStorage(scope: string): OutboxStorage {
old &&
old.delivery === operation.delivery &&
old.error === operation.error &&
-old.signed?.id === operation.signed?.id &&
+ old.signed?.id === operation.signed?.id &&
old.recovery?.key === operation.recovery?.key &&
old.recovery?.value === operation.recovery?.value &&
old.guarded === operation.guarded
diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts
index 8c8d69d1b..959cf9fa7 100644
--- a/src/features/relay/outbox.test.ts
+++ b/src/features/relay/outbox.test.ts
@@ -876,7 +876,7 @@ it("fences caller-scoped admission before signing and at publisher entry", async
],
};
let active = true;
- const first = h.outbox.send(template, () => active);
+ const first = h.outbox.send(template, undefined, () => active);
active = false;
await vi.waitFor(() =>
expect(
@@ -965,6 +965,7 @@ it("does not replay a guarded addition through generic retry or after hydration"
["p", "a".repeat(64)],
],
},
+ undefined,
() => active,
);
active = false;
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index 2af09b43c..22c6f1186 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -27,7 +27,8 @@ export type OutgoingEvent = Readonly<{
signed?: RelayEvent;
recovery?: OutboxRecovery | undefined;
/** A caller-scoped admission requires renewed live eligibility for retry. */
- guarded?: boolean; delivery: Delivery;
+ guarded?: boolean;
+ delivery: Delivery;
error?: string | undefined;
}>;
export interface Outbox {
@@ -45,7 +46,8 @@ export interface Outbox {
active?: () => boolean,
): string;
acknowledge(id: string): Promise;
- retry(id: string, active?: () => boolean): void; dismiss(id: string): Promise;
+ retry(id: string, active?: () => boolean): void;
+ dismiss(id: string): Promise;
}
type SendObserver = (
event: EventData,
@@ -282,7 +284,8 @@ export function createOutbox(
}),
...(signed ? { signed } : {}),
...(item.recovery ? { recovery: recoveryValue(item.recovery) } : {}),
- ...(item.guarded ? { guarded: true } : {}), delivery:
+ ...(item.guarded ? { guarded: true } : {}),
+ delivery:
item.delivery === "seen" && signed
? "seen"
: item.delivery === "failed"
@@ -537,7 +540,8 @@ export function createOutbox(
supports: (kind: number) =>
!closed && (!writer.kinds || writer.kinds.includes(kind)),
async ready() {
- await ready; if (closed) throw abortError();
+ await ready;
+ if (closed) throw abortError();
if (storageError) throw new Error(storageError);
},
async acknowledge(id: string) {
@@ -606,7 +610,8 @@ export function createOutbox(
delivery: "sending" as const,
...(savedRecovery ? { recovery: savedRecovery } : {}),
...(active ? { guarded: true } : {}),
- }), ]);
+ }),
+ ]);
notify();
});
const intent = ready.then(() => {
diff --git a/src/features/relay/work-sessions.ts b/src/features/relay/work-sessions.ts
index 8de9b028a..b962cb978 100644
--- a/src/features/relay/work-sessions.ts
+++ b/src/features/relay/work-sessions.ts
@@ -300,6 +300,7 @@ export function createWorkSessions(
["p", key],
],
},
+ undefined,
active,
);
await delivered(operation, active, true);
From 499b1cb8245e7c593faee2f15c0bc2aa593f8f90 Mon Sep 17 00:00:00 2001
From: am
<6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Date: Thu, 24 Sep 2026 09:26:09 -0700
Subject: [PATCH 7/7] test: identify unexpected relay publication kinds
Co-authored-by: Kalvin Chau
Signed-off-by: Kalvin Chau
---
tests/browser/policy-relay.mjs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs
index 4de74b6a8..c6592650d 100644
--- a/tests/browser/policy-relay.mjs
+++ b/tests/browser/policy-relay.mjs
@@ -403,7 +403,12 @@ export function policyRelay({
}, latencyMs);
return;
}
- expect(kind).toBe("REQ");
+ expect(
+ kind,
+ kind === "EVENT"
+ ? `Unexpected publication kind ${id?.kind}`
+ : `Unexpected relay frame ${kind}`,
+ ).toBe("REQ");
expect(this.authenticated).toBe(true);
requests.push({
socket: sockets.indexOf(this),