diff --git a/docs/channels.md b/docs/channels.md
index cbc285b5b..7e51cc127 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -817,9 +817,11 @@ another non-owner member:
| Target role | Change role | Remove from this channel |
| --- | --- | --- |
| Admin / Member / Guest | Admin / Member, excluding the current role | Yes |
-| Bot | No conversion | Yes |
+| Bot | Admin / Member, after explicit confirmation | Yes |
| Owner, self, unknown or inconsistent | No | No |
+Agent identity is independent of channel role. Explicitly promoting a Bot to Admin grants authority to that agent’s own public key and preserves its verified agent identity; the human owner’s roles do not confer authority.
+
The menu deliberately omits **Make guest** while Guest's permission contract is
unsettled: the inspected relay message path does not enforce the role's documented
read-only meaning, while Git push policy does distinguish Guest from Member.
diff --git a/src/bundled/channels/MemberAdministration.test.tsx b/src/bundled/channels/MemberAdministration.test.tsx
index 557127fb0..b35c7ffcc 100644
--- a/src/bundled/channels/MemberAdministration.test.tsx
+++ b/src/bundled/channels/MemberAdministration.test.tsx
@@ -605,7 +605,7 @@ it("changes an existing Guest to Member only after explicit confirmation", async
expect(t.publish).toHaveBeenCalledOnce();
});
it.each([false, true])(
- "groups Bot by identity while retaining its accessible role and removal-only policy (Agent: %s)",
+ "groups Bot by identity while retaining its accessible role and explicit role controls (Agent: %s)",
async (agent) => {
const t = await setup("owner", "bot", true, undefined, agent);
const row = screen.getByRole("button", { name: /Open profile for Morgan/ });
@@ -637,9 +637,8 @@ it.each([false, true])(
expect(
await screen.findByRole("menuitem", { name: "Remove from channel" }),
).toBeVisible();
- expect(
- screen.queryByRole("menuitem", { name: /Make / }),
- ).not.toBeInTheDocument();
+ expect(screen.getByRole("menuitem", { name: "Make admin" })).toBeVisible();
+ expect(screen.getByRole("menuitem", { name: "Make member" })).toBeVisible();
expect(t.publish).not.toHaveBeenCalled();
},
);
@@ -1377,3 +1376,24 @@ it("returns to All when a refreshed role group disappears and resets on reopen",
await screen.findByText("Morgan");
expect(filter()).toHaveTextContent("All");
});
+
+it.each(["owner", "admin"])(
+ "lets %s explicitly promote a bot while preserving verified agent identity",
+ async (actor) => {
+ const t = await setup(actor, "bot", true, undefined, true);
+ const dialog = await t.choose("Make admin");
+ expect(dialog).toHaveTextContent("from bot to admin");
+ expect(t.publish).not.toHaveBeenCalled();
+ await t.user.click(
+ within(dialog).getByRole("button", { name: "Make admin" }),
+ );
+ await screen.findByText("Member change confirmed.");
+ const row = screen.getByRole("button", { name: /Open profile for Morgan/ });
+ expect(row.closest("section")).toHaveAttribute("aria-label", "Admins");
+ expect(
+ row.closest("li")?.querySelector('[data-avatar-shape="squircle"]'),
+ ).toBeInTheDocument();
+ expect(row).toHaveAccessibleName(/, admin$/);
+ expect(t.publish).toHaveBeenCalledOnce();
+ },
+);
diff --git a/src/bundled/channels/MemberAdministration.tsx b/src/bundled/channels/MemberAdministration.tsx
index 2d428a251..320c04f29 100644
--- a/src/bundled/channels/MemberAdministration.tsx
+++ b/src/bundled/channels/MemberAdministration.tsx
@@ -203,16 +203,15 @@ export function MemberRow({
{permitted && !locked && (
<>
- {role !== "bot" &&
- editableMemberRoles
- // Guest assignment is hidden until its permission contract is settled.
- .filter((next) => next !== role && next !== "guest")
- .map((next) => (
-
- ))}
- {role !== "bot" && }
+ {editableMemberRoles
+ // Guest assignment is hidden until its permission contract is settled.
+ .filter((next) => next !== role && next !== "guest")
+ .map((next) => (
+
+ ))}
+
diff --git a/src/features/channel-members/administration-protocol.ts b/src/features/channel-members/administration-protocol.ts
index 4bccad190..045149822 100644
--- a/src/features/channel-members/administration-protocol.ts
+++ b/src/features/channel-members/administration-protocol.ts
@@ -120,8 +120,7 @@ export function authorizeMemberChange(
!canManageMember(state, viewer, change.pubkey) ||
state.roles[change.pubkey] !== change.expectedRole ||
(change.role !== "remove" &&
- (change.expectedRole === "bot" ||
- change.expectedRole === change.role ||
+ (change.expectedRole === change.role ||
!editableMemberRoles.includes(change.role)))
)
throw new Error(
diff --git a/src/features/channel-members/administration.test.ts b/src/features/channel-members/administration.test.ts
index 4f3266631..f2c7b3650 100644
--- a/src/features/channel-members/administration.test.ts
+++ b/src/features/channel-members/administration.test.ts
@@ -131,6 +131,23 @@ it.each(["admin", "member", "guest", "remove"] as const)(
]);
},
);
+it.each(["owner", "admin"])(
+ "allows %s to explicitly promote a bot to admin with fresh role confirmation",
+ async (actor) => {
+ const h = harness(actor, "bot");
+ await h.owner.capability.run(id, { ...change, expectedRole: "bot" });
+ expect(h.publish).toHaveBeenCalledOnce();
+ expect(h.publish.mock.calls[0]?.[0].tags).toEqual([
+ ["h", id],
+ ["p", target],
+ ["role", "admin"],
+ ]);
+ expect(h.owner.capability.snapshot(id).operation?.status).toBe("confirmed");
+ expect(h.owner.capability.snapshot(id).authority.roles[target]).toBe(
+ "admin",
+ );
+ },
+);
it.each(["owner", "bot", "unknown"])(
"never coerces %s target roles",
async (role) => {
@@ -154,6 +171,15 @@ it("allows removing a bot without changing its role or invoking agent deletion",
],
});
});
+it.each(["member", "guest", "bot"])(
+ "rejects explicit bot promotion by a %s viewer",
+ async (actor) => {
+ const h = harness(actor, "bot");
+ await h.owner.capability.run(id, { ...change, expectedRole: "bot" });
+ expect(h.sign).not.toHaveBeenCalled();
+ expect(h.publish).not.toHaveBeenCalled();
+ },
+);
it.each(["member", "guest", "bot"])(
"never grants administration to %s viewers",
async (role) => {