feat(teams): phase 9 — one voice channel per Team, granted by a role #159

Merged
whitlocktech merged 2 commits from feature/teams-phase9-voice-channels into edge 2026-08-19 05:21:18 +00:00
Member

Phase 9 of docs/website/TEAMS.md. Every Team above the operator's size threshold gets a voice channel of its own in Discord, visible and joinable by its members and nobody else, kept in step by a reconciler that rides the Team reconcile it already depends on.

Pairs with docs#163.

ONE code repo, not the plan's website + bot

bot is a workspace inside website — the same correction phases 7 and 8 made. Nothing here belongs to a module: the resource is core's own and the grant set comes from core's own identity chain, so module-uo is untouched and MODULE_API_VERSION does not move.

Org-lead decisions taken before building

  • Access is a per-Team role, always. Not overwrites-by-default with escalation above ~90 members.
  • The bot creates the parent category and the server stores its id in settings.
  • The threshold counts every active member, not linked ones.
  • "Staff" is a list of Discord roles the admin designates, because the concept does not otherwise exist.

Roles-always moves the ceiling, and that is the part worth reading

overwrites (§7.3 as designed) roles (as built)
Limit ~100 overwrites per channel 250 roles per guild
So the ceiling is how big ONE Team can be how many TEAMS can have voice
Visible to other members no yes — a role shows on a profile

A limit on the number of Teams is one an operator has to be told about before they hit it, so the panel reports the guild's role count against the cap and the pass refuses the create rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the cap is shared with every role the operator made themselves. voice_overwrite_max, the escalation and the mode column all leave the design.

Three things §7.3 named that this codebase does not have

All settled by asking the operator, because nothing in the data model can answer.

  1. "the staff role". There is no staff-role concept anywhere — guild_config knows a news channel, a modlog channel, an autorole and a filter allowlist, and none of them means staff. Now teams_voice_staff_roles, a list of role ids, empty by default and a perfectly ordinary answer: guild administrators bypass channel overwrites already, so what was actually missing is a way to let non-admin staff in. A role the operator has since deleted is filtered by the bot rather than sent, because Discord rejects an entire overwrite set for one bad id — which would take the Team's own grant down with it.
  2. The parent category's identity. §7.3 says the bot creates it and gives the id nowhere to live: team_integrations.team_id is NOT NULL, so it cannot be a row in there. The bot creates it and the server stores the id in settings — written by the server after the fact, not typed by an admin into a field they could point at something that is not a category.
  3. Whether the bot can act at all. §7.3 assumes it can manage channels and roles and nothing in this project has ever checked: the operator invites the bot by hand and no invite URL with a permission integer exists anywhere in the tree, so a deployment can sit one unticked box away from every call failing with only a column of identical per-Team errors to show for it. A preflight is now a precondition — enabling is refused 422 while the bot is disconnected or missing a permission, in the shape §7.2's acknowledgement refuses — and is re-asked at the top of every pass. Switching voice OFF is never gated: an operator disabling a feature because it is misbehaving must not be blocked by the misbehaviour. The preflight also reports the bot's own role position, because that is the quieter failure — Manage Roles lets it create a role but it can only grant roles below its own, so a bot at the bottom of the list makes roles it cannot hand to anybody.

Two more, decided rather than asked

  • The threshold reads teams.member_count, and deliberately not linked_count — which counts hop 1 (has a site account) and is a third quantity again from §7.3's "linked". The grant set is hop 3: a site account and a Discord identity and present in the guild. A member missing the last is skipped silently — §2.6 hop 3 without hop 4 is an ordinary state, not a hundred log lines.
  • Hidden Teams are never provisioned. A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern — utils/reservedNames.js already names "and eventually a Discord channel name" among the surfaces it protects. The screen that suppresses a Team's page suppresses its channel, and a Team that becomes hidden takes the grace window. The interlock costs one hidden = 0 in one query rather than a second policy that could drift. The name published is display_name_override || name, since §2.8.3 makes that the display surface's answer.

Switching voice off tears nothing down. The pass suspends in both directions and existing channels are left standing, inert; the panel says how many remain and offers to remove them one at a time. A checkbox must not delete structure in somebody's guild, and per-row removal is also the only way to clean up while voice is off.

The live rig

Real MariaDB on a throwaway database, the real app with a real Team provider registered, and a fake standing in for Discord that mounts the bot's real internal routes — so everything from the internal-key middleware through the controller and bot/src/discord/teamVoice.js was production code. 47 assertions.

It proved: the preflight refusing an enable three different ways, and the panel still rendering with a broken bot; a category, role and channel created with @everyone denied and the Team role allowed; the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in the guild and not to the one who linked Discord without joining it; a drop below the threshold scheduling a removal with zero Discord calls; a recovery inside the window keeping the same channel id; an expired window deleting the channel and the role and forgetting the row; a stale projection suspending the pass in both directions with the row not even scheduled; voice switched off leaving channels standing; an admin removal working anyway, and 404 for a Team that has none.

Unlike §7.2's DDL, this table applied to real MariaDB exactly as written.

It found two defects, both fixed with regression tests in the second commit.

  1. Every query failed on a duplicate result column. desiredTeams and holdersWithoutClaim both select t.id AS team_id, and the shared column list added i.team_id beside it — which the mariadb driver refuses outright. The pass died at its first query, on the one code path every unit test stubs. It was also the wrong column: desiredTeams LEFT JOINs, so i.team_id is NULL for exactly the Teams that have no channel yet. The regression test checks the interpolated sql captured from a fake query — a first attempt that read the source text passed happily with the bug put back, because in the source the shared list is still a ${COLUMNS} placeholder.
  2. "Sync now" reported "Nothing was done" while it was doing it. Saving the settings with voice on asks for a pass; an operator pressing Sync now next — the obvious thing — hit running and got "a pass is already running", which the panel renders as nothing having happened, while the pass they just triggered created their channels. A pass in flight is now joined and its real outcome returned, as teamSync.reconcileNow does.

Two things outside this phase that it had to work around

  • npm run swagger could not run at all on edge — so this phase could not produce its own required artifact. Phase 8 shipped param('teamId').custom((v) => … || /^[0-9]+$/.test(v)), and a regex literal followed directly by .test( makes swagger-autogen's parser run away until the process dies out of memory. Hoisted to a const, as modules.router.js already does. Underneath it, teams.router.js sits exactly at that parser's per-file limit: at twenty teamsRouter.* statements it dies and at nineteen it generates, and one more statement of any shape tips it — an unannotated route does, and so does a bare use. The same probe route in discordBot.router.js is fine, so the limit is per-file. Hence teamsVoice.router.js, mounted from admin/index.js at the more specific prefix so /teams/voice/* never reaches /:id.
  • last_success_at is written by MariaDB's NOW() and compared against JS Date.now(). An app process and a database in different timezones therefore skew every staleness judgement by the offset — on this machine, five hours in the direction that makes a stale projection read as fresh. That moves §3's public freshness banner as much as this phase's suspension. Pre-existing and not fixed here; recorded because it is invisible until something depends on it.

Also fixed, because this phase would otherwise have introduced it: the require cycle teamSync → teamVoiceSync → teams.model → teamSync, which left teams.model holding the reconciler's exports object as it stood mid-load — the empty one, since module.exports = {…} replaces rather than fills. The symptom is not in the new code: it is teamSync.intervalSeconds is not a function thrown out of syncStatus(), the freshness banner on every public Team page.

Tests

1162 server (+42), 53 bot (+21), 284 client (+21). Swagger, routes.manifest.json and routes.guards.json regenerated — the four new routes' guard shape is byte-identical to the existing admin-only ones.

Not done here

No real Discord guild was involved. A live guild.channels.create, roles.create and members.fetch are what this walk could not exercise — the same gap phases 7 and 8 recorded.


  • AI-assisted: Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz

Phase 9 of `docs/website/TEAMS.md`. Every Team above the operator's size threshold gets a voice channel of its own in Discord, visible and joinable by its members and nobody else, kept in step by a reconciler that rides the Team reconcile it already depends on. Pairs with **docs#163**. ## ONE code repo, not the plan's `website` + `bot` `bot` is a workspace inside `website` — the same correction phases 7 and 8 made. Nothing here belongs to a module: the resource is core's own and the grant set comes from core's own identity chain, so `module-uo` is untouched and `MODULE_API_VERSION` does not move. ## Org-lead decisions taken before building - **Access is a per-Team role, always.** Not overwrites-by-default with escalation above ~90 members. - **The bot creates the parent category** and the server stores its id in settings. - **The threshold counts every active member**, not linked ones. - **"Staff" is a list of Discord roles the admin designates**, because the concept does not otherwise exist. ## Roles-always moves the ceiling, and that is the part worth reading | | overwrites (§7.3 as designed) | roles (as built) | | --- | --- | --- | | Limit | ~100 overwrites **per channel** | 250 roles **per guild** | | So the ceiling is | how big ONE Team can be | how many TEAMS can have voice | | Visible to other members | no | yes — a role shows on a profile | A limit on the number of Teams is one an operator has to be told about *before* they hit it, so the panel reports the guild's role count against the cap and the pass refuses the create rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the cap is shared with every role the operator made themselves. `voice_overwrite_max`, the escalation and the `mode` column all leave the design. ## Three things §7.3 named that this codebase does not have All settled by asking the operator, because nothing in the data model can answer. 1. **"the staff role".** There is no staff-role concept anywhere — `guild_config` knows a news channel, a modlog channel, an autorole and a filter allowlist, and none of them means staff. Now `teams_voice_staff_roles`, a list of role ids, **empty by default and a perfectly ordinary answer**: guild administrators bypass channel overwrites already, so what was actually missing is a way to let *non-admin* staff in. A role the operator has since deleted is filtered by the bot rather than sent, because Discord rejects an entire overwrite set for one bad id — which would take the Team's own grant down with it. 2. **The parent category's identity.** §7.3 says the bot creates it and gives the id nowhere to live: `team_integrations.team_id` is `NOT NULL`, so it cannot be a row in there. The bot creates it and the server stores the id in settings — written by the server after the fact, not typed by an admin into a field they could point at something that is not a category. 3. **Whether the bot can act at all.** §7.3 assumes it can manage channels and roles and nothing in this project has ever checked: the operator invites the bot by hand and **no invite URL with a permission integer exists anywhere in the tree**, so a deployment can sit one unticked box away from every call failing with only a column of identical per-Team errors to show for it. A preflight is now a **precondition** — enabling is refused **422** while the bot is disconnected or missing a permission, in the shape §7.2's acknowledgement refuses — and is re-asked at the top of every pass. Switching voice OFF is never gated: an operator disabling a feature because it is misbehaving must not be blocked by the misbehaviour. The preflight also reports the bot's own role position, because that is the quieter failure — Manage Roles lets it create a role but it can only grant roles below its own, so a bot at the bottom of the list makes roles it cannot hand to anybody. ## Two more, decided rather than asked - **The threshold reads `teams.member_count`, and deliberately not `linked_count`** — which counts hop 1 (has a site account) and is a third quantity again from §7.3's "linked". The **grant** set is hop 3: a site account *and* a Discord identity *and* present in the guild. A member missing the last is skipped silently — §2.6 hop 3 without hop 4 is an ordinary state, not a hundred log lines. - **Hidden Teams are never provisioned.** A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern — `utils/reservedNames.js` already names "and eventually a Discord channel name" among the surfaces it protects. The screen that suppresses a Team's page suppresses its channel, and a Team that *becomes* hidden takes the grace window. The interlock costs one `hidden = 0` in one query rather than a second policy that could drift. The name published is `display_name_override || name`, since §2.8.3 makes that the display surface's answer. **Switching voice off tears nothing down.** The pass suspends in both directions and existing channels are left standing, inert; the panel says how many remain and offers to remove them one at a time. A checkbox must not delete structure in somebody's guild, and per-row removal is also the only way to clean up while voice is off. ## The live rig Real MariaDB on a **throwaway database**, the real app with a real Team provider registered, and a fake standing in for Discord that mounts the bot's **real** internal routes — so everything from the internal-key middleware through the controller and `bot/src/discord/teamVoice.js` was production code. **47 assertions.** It proved: the preflight refusing an enable three different ways, and the panel still rendering with a broken bot; a category, role and channel created with `@everyone` denied and the Team role allowed; the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in the guild and **not** to the one who linked Discord without joining it; a drop below the threshold scheduling a removal **with zero Discord calls**; a recovery inside the window keeping the same channel id; an expired window deleting the channel *and* the role and forgetting the row; a stale projection suspending the pass in both directions with the row not even scheduled; voice switched off leaving channels standing; an admin removal working anyway, and 404 for a Team that has none. Unlike §7.2's DDL, this table applied to real MariaDB exactly as written. **It found two defects, both fixed with regression tests in the second commit.** 1. **Every query failed on a duplicate result column.** `desiredTeams` and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared column list added `i.team_id` beside it — which the `mariadb` driver refuses outright. The pass died at its first query, on the one code path every unit test stubs. It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel yet. The regression test checks the **interpolated** sql captured from a fake `query` — a first attempt that read the source text passed happily with the bug put back, because in the source the shared list is still a `${COLUMNS}` placeholder. 2. **"Sync now" reported "Nothing was done" while it was doing it.** Saving the settings with voice on asks for a pass; an operator pressing Sync now next — the obvious thing — hit `running` and got "a pass is already running", which the panel renders as nothing having happened, while the pass they just triggered created their channels. A pass in flight is now **joined** and its real outcome returned, as `teamSync.reconcileNow` does. ## Two things outside this phase that it had to work around - **`npm run swagger` could not run at all on `edge`** — so this phase could not produce its own required artifact. Phase 8 shipped `param('teamId').custom((v) => … || /^[0-9]+$/.test(v))`, and a regex literal followed directly by `.test(` makes swagger-autogen's parser run away until the process dies out of memory. Hoisted to a const, as `modules.router.js` already does. Underneath it, **`teams.router.js` sits exactly at that parser's per-file limit**: at twenty `teamsRouter.*` statements it dies and at nineteen it generates, and one more statement of *any* shape tips it — an unannotated route does, and so does a bare `use`. The same probe route in `discordBot.router.js` is fine, so the limit is per-file. Hence `teamsVoice.router.js`, mounted from `admin/index.js` at the more specific prefix so `/teams/voice/*` never reaches `/:id`. - **`last_success_at` is written by MariaDB's `NOW()` and compared against JS `Date.now()`.** An app process and a database in different timezones therefore skew every staleness judgement by the offset — on this machine, five hours in the direction that makes a stale projection read as fresh. That moves §3's public freshness banner as much as this phase's suspension. Pre-existing and **not fixed here**; recorded because it is invisible until something depends on it. Also fixed, because this phase would otherwise have introduced it: the require cycle `teamSync → teamVoiceSync → teams.model → teamSync`, which left `teams.model` holding the reconciler's exports object as it stood mid-load — the empty one, since `module.exports = {…}` replaces rather than fills. The symptom is not in the new code: it is `teamSync.intervalSeconds is not a function` thrown out of `syncStatus()`, the freshness banner on every public Team page. ## Tests 1162 server (+42), 53 bot (+21), 284 client (+21). Swagger, `routes.manifest.json` and `routes.guards.json` regenerated — the four new routes' guard shape is byte-identical to the existing admin-only ones. ## Not done here No real Discord guild was involved. A live `guild.channels.create`, `roles.create` and `members.fetch` are what this walk could not exercise — the same gap phases 7 and 8 recorded. --- - [x] AI-assisted: Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
wtclaude added 2 commits 2026-08-19 05:11:45 +00:00
TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own
and a role that opens it, kept in step by a reconciler that rides the Team
reconcile it already depends on.

Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with
escalation to a role above ~90 members; the org lead settled on roles always
(2026-08-18), which deletes `voice_overwrite_max`, the escalation and the
`mode` column — and moves the ceiling. Overwrites are capped per channel, so
the old shape's limit was "how big can one Team be"; roles are capped per
guild at 250, so the new one is "how many Teams can have voice at all". That
is a limit an operator must be told about before they hit it, so the panel
reports it and the pass refuses the create rather than letting Discord do it.

Three things §7.3 named that this codebase does not have, all settled by
asking the operator because nothing in the data model can answer:

  - "the staff role" — there is no staff-role concept anywhere. Now a list of
    role ids the admin designates; empty is a normal answer, since guild
    administrators bypass overwrites and what is really missing is a way to
    let NON-admin staff in.
  - the parent category — §7.3 said the bot creates it and gave the id nowhere
    to live (`team_integrations.team_id` is NOT NULL). The bot creates it and
    the server stores the id in settings.
  - whether the bot can act at all — nothing has ever checked. The operator
    invites the bot by hand and no invite URL with a permission integer exists
    in the tree, so a deployment can be one unticked box from every call
    failing. A preflight is now a PRECONDITION to enabling (422), not a
    per-Team error discovered afterwards.

Two more, decided rather than asked:

  - the threshold counts every active member, not linked ones. §7.3 wrote
    `voice_min_linked_members`; the operator is judging whether a Team is real,
    and link state answers a different question.
  - hidden Teams are never provisioned. A channel name is a game-sourced string
    published outside the site, which is exactly §2.8's concern —
    reservedNames.js already names "and eventually a Discord channel name" as a
    surface it protects — so the screen that suppresses a Team's page suppresses
    its channel, and a Team that becomes hidden takes the grace window.

Turning voice OFF tears nothing down: the pass suspends in both directions and
the panel offers per-row removal. A checkbox must not delete structure in
somebody's guild.

Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger`
has been unable to run on `edge` at all. `param('teamId').custom((v) => ... ||
/^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal
followed directly by `.test(`. Hoisted to a const, as modules.router.js
already does. Underneath it, `teams.router.js` sits exactly at that parser's
per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it
generates, and one more statement of ANY shape tips it — an unannotated route
does, and so does a bare `use`. So the voice routes are their own router file
mounted from `admin/index.js`, and teams.router.js keeps its nineteen.

Also breaks a require cycle this phase would have introduced:
teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding
the reconciler's exports object as it stood mid-load — the empty one, since
`module.exports = {…}` replaces rather than fills. The symptom is not in the
new code: it is `teamSync.intervalSeconds is not a function` thrown out of
`syncStatus()`, the freshness banner on every public Team page.

Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes
manifest and guards regenerated; the guard shape of the four new routes is
byte-identical to the existing admin-only ones.

Co-Authored-By: Claude <noreply@anthropic.com>
fix(teams): the two defects the phase 9 rig walk found
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 2m33s
f72c92ffbe
Walked against real MariaDB, the real app, and a fake standing in for Discord
that mounts the bot's real internal routes — everything up to the Discord API
call was production code. 47 assertions, and it found two things every unit
test in the phase had passed over.

1. **Every query failed: two result columns named `team_id`.** `desiredTeams`
   and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared
   column list added `i.team_id` beside it. The `mariadb` driver refuses a
   result set with a repeated field name outright, so the pass died at its
   first query with "Error in results, duplicate field name `team_id`" — on the
   one code path every unit test stubs.

   It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is
   NULL for exactly the Teams that have no channel yet, which is the create
   case. The two queries that do not join `teams` now ask for it by name.

   The regression test checks the INTERPOLATED sql captured from a fake
   `query`, not the source text — in the source the shared list is still a
   `${COLUMNS}` placeholder, and a first attempt that read the file passed
   happily with the bug reintroduced.

2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the
   settings with voice switched on asks for a pass. An operator who then
   presses Sync now — the obvious next thing — hit `running` and got back
   `ran: false, reason: "a pass is already running"`, which the panel renders
   as nothing having happened, while the pass they triggered was busy creating
   their channels. A pass in flight is now JOINED and its real outcome
   returned, the same choice `teamSync.reconcileNow` makes for the same reason.

Tests: 1162 server (+2), 53 bot, 284 client.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 9619fdf1e1 into edge 2026-08-19 05:21:18 +00:00
whitlocktech deleted branch feature/teams-phase9-voice-channels 2026-08-19 05:21:18 +00:00
Sign in to join this conversation.
No description provided.