docs(teams): the Teams bet, as built (Teams cutover 6/6) #169

Merged
whitlocktech merged 32 commits from edge into main 2026-08-19 09:02:16 +00:00
2 changed files with 181 additions and 7 deletions
Showing only changes of commit c87034d7fe - Show all commits

View File

@@ -554,6 +554,7 @@ core's.
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist | | `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email**`muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer | | `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email**`muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it | | `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it |
| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error |
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum**`target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer | | `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum**`target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`, **Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,

View File

@@ -1877,9 +1877,35 @@ Two smaller consequences of the same asymmetry:
### 7.3 One voice channel per Team ### 7.3 One voice channel per Team
> **Amended 2026-08-19, as built (phase 9).** The org lead settled the access model as **a per-Team
> role, always** — the escalation below is gone, and with it `voice_overwrite_max` and the `mode`
> column. Three things this section names turned out not to exist in the tree at all, and one number
> it relies on counts something different from what it says. Each is marked inline; "as built" wins
> over the original wording wherever they disagree.
**Shape.** One voice channel per qualifying Team, under a single shared parent category **Shape.** One voice channel per qualifying Team, under a single shared parent category
(`Teams`), created by the bot. **No per-Team role by default**, no auto-created category per Team. (`Teams`), created by the bot. **No per-Team role by default**, no auto-created category per Team.
> **As built: a per-Team role, always.** Overwrites-by-default with escalation was designed to spend
> the scarcer guild-wide resource only where the per-channel budget actually ran out. Roles-always is
> one code path instead of two plus a transition, and it makes the grant a thing a member can be given
> and taken rather than a channel-shaped list — but it moves the ceiling, and that is the part worth
> stating plainly:
>
> | | overwrites (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 reach it, so
> the admin panel reports the guild's role count against the cap and the reconciler 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.
>
> That a Team's membership becomes visible guild-wide on each member's profile is the trade this
> bought. It is not per-deployment configurable.
**Access, and why overwrites are enough — with a stated fallback.** Access is `@everyone` deny + **Access, and why overwrites are enough — with a stated fallback.** Access is `@everyone` deny +
`VIEW_CHANNEL`/`CONNECT` allow per **linked** Team member (path 4, §2.5) + the staff role. Discord's `VIEW_CHANNEL`/`CONNECT` allow per **linked** Team member (path 4, §2.5) + the staff role. Discord's
practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for
@@ -1889,10 +1915,58 @@ members — because roles are the scarcer guild-wide resource (250 cap) and shou
overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded
in `team_integrations.mode`. in `team_integrations.mode`.
> **As built.** The channel carries exactly three kinds of overwrite: `@everyone` denied, the Team's
> own role allowed, and one allow per operator-designated staff role. Membership is the role's member
> list. There is no `mode`, no `voice_overwrite_max` and no escalation.
>
> **"the staff role" does not exist in this codebase.** `guild_config` knows a news channel, a modlog
> channel, an autorole and a filter allowlist; none of them means "staff", and core has no way to
> derive one. Guild administrators bypass channel overwrites anyway, so what is actually missing is a
> way to let **non-admin** staff in — and only the operator can say which of their roles those are.
> As built: `teams_voice_staff_roles`, a list of role ids, **empty by default and a perfectly ordinary
> answer**. A role the operator has since deleted is filtered out by the bot rather than sent, because
> Discord rejects an entire overwrite set for one bad id and that would take the Team's own grant down
> with it.
>
> **The grant set is hop 3, not path 4's "linked".** A role can only be given to somebody Discord
> knows, so the set is Team members who have a site account *and* a `user_identities` row for Discord
> *and* are in the guild. A member missing the last of those is skipped silently — it is §2.6's hop 3
> without hop 4, an ordinary state, not an error worth a hundred log lines.
**Provisioning gate.** Admin opt-in per deployment, plus `voice_min_linked_members` (default 5). **Provisioning gate.** Admin opt-in per deployment, plus `voice_min_linked_members` (default 5).
Counted on **linked** members only, since an unlinked member cannot be granted anything on Discord Counted on **linked** members only, since an unlinked member cannot be granted anything on Discord
anyway. anyway.
> **As built: `teams_voice_min_members`, counting EVERY active member** (org lead, 2026-08-19). The
> question an operator is answering with this number is "is this Team real enough to deserve a
> channel", and link state answers a different one. Note that this is deliberately *not*
> `teams.linked_count` either — that column counts hop 1 (has a site account), which is a third
> quantity again.
>
> **Two more gates the original does not mention, both required:**
>
> - **A hidden Team is 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. So the screen that suppresses a Team's
> public page suppresses its channel, and a Team that *becomes* hidden takes the grace window like
> any other removal. The interlock costs one `hidden = 0` in one query rather than a second policy
> that could drift from the first. The name published is `display_name_override || name` — §2.8.3
> lets staff change what is displayed, and a channel is a display surface.
> - **The bot must actually be able to act.** This section assumes it can manage channels and roles;
> nothing in this project has ever checked. The operator invites the bot by hand and there is no
> invite URL with a permission integer 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.
> As built, a **preflight is a precondition**: `PUT /admin/teams/voice` with `enabled: true` is
> refused **422** while the bot is disconnected or missing Manage Channels or Manage Roles, in the
> same shape §7.2's acknowledgement refuses. It is asked again 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 second, quieter
> failure: Manage Roles lets the bot create a role, but it can only grant roles *below* its own
> highest. A bot at the bottom of the list creates roles it cannot hand to anybody, which looks exactly
> like a channel nobody can enter.
**Lifecycle: delete, but after a grace window.** Justification, since the brief asks for one: **Lifecycle: delete, but after a grace window.** Justification, since the brief asks for one:
- A voice channel holds **no message history**, so deletion destroys nothing recoverable. The - A voice channel holds **no message history**, so deletion destroys nothing recoverable. The
@@ -1908,34 +1982,83 @@ So: drop below threshold → `state='pending_removal'`, `remove_after` = now + `
expiry → delete. A Team **archived** (disbanded or renamed) takes the same window, because "disbanded" expiry → delete. A Team **archived** (disbanded or renamed) takes the same window, because "disbanded"
can be a missed event and 7 days is cheap insurance. can be a missed event and 7 days is cheap insurance.
> **As built, with one narrowing.** "Recover inside the window → **no Discord call made**" is not
> quite what happens, and the truer promise is **no DESTRUCTIVE call**. A Team that climbed back above
> the threshold has members who need granting, and the ordinary membership diff is what grants them;
> refusing to call at all would leave the very people who brought it back outside the channel. What
> the recovery cancels is the deletion, and the channel id is unchanged — which is the whole point.
>
> A **failed teardown keeps the expired window** rather than being rescheduled. Granting another seven
> days each time a delete fails means it never happens.
>
> **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 an operator trying the feature
> out must be able to stop trying it without consequences. Per-row removal is also the only way to
> clean up while voice is off, since no pass will ever reach those rows.
**And never on stale data.** If `team_sync_state` is stale for the module (§2.4), the integration **And never on stale data.** If `team_sync_state` is stale for the module (§2.4), the integration
reconciler **skips entirely** — no creation, no deletion, no overwrite changes. A voice channel is never reconciler **skips entirely** — no creation, no deletion, no overwrite changes. A voice channel is never
destroyed because a sidecar was down. destroyed because a sidecar was down.
> **As built, and proved on the rig** — a stale projection stops the pass before a single Discord call,
> in both directions, with the row not even scheduled for removal.
>
> One boundary worth knowing: `teams.model.syncStatus()` reports `stale: false` when **no** Team
> provider is registered, on the reasoning that a deployment with no game module is not a broken one.
> So on a deployment whose module has been uninstalled this suspension is inactive — which is benign,
> because with nothing updating the projection the member counts do not move and the reconciler has
> nothing to act on.
```sql ```sql
CREATE TABLE IF NOT EXISTS team_integrations ( CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL, team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, platform VARCHAR(32) NOT NULL, -- 'discord'
resource VARCHAR(32) NOT NULL, -- 'voice' resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id external_ref VARCHAR(64) NULL, -- the channel id
mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites', role_ref VARCHAR(64) NULL, -- the Team's role: the grant itself
role_ref VARCHAR(64) NULL,
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none', state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL, remove_after DATETIME NULL,
last_error VARCHAR(500) NULL, last_error VARCHAR(500) NULL,
synced_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource), UNIQUE KEY uq_team_integration (team_id, platform, resource),
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
``` ```
> **As built** — `mode` and `role_ref`-as-escalation are gone; `role_ref` is now the grant itself, so a
> row with a channel and no role is a broken row. `synced_at` is added: `updated_at` moves whenever
> core writes a belief, including an error, and "when did this last actually reach Discord" is a
> different question. Unlike §7.2's DDL, this one applied to real MariaDB exactly as written.
**Sync** rides the same reconciliation as membership: after a successful Team reconcile, the **Sync** rides the same reconciliation as membership: after a successful Team reconcile, the
integration reconciler diffs the desired access set (path 4) against what the bot reports and issues integration reconciler diffs the desired access set (path 4) against what the bot reports and issues
the minimum set of calls. Every call is best-effort; a failure records `state='error'` with the message the minimum set of calls. Every call is best-effort; a failure records `state='error'` with the message
and retries on the next pass. It never blocks the Team sync. and retries on the next pass. It never blocks the Team sync.
--- > **As built, with the diff on the bot's side.** Core sends the DESIRED STATE for one Team — name,
> category, channel, role, staff roles, the member id list — and the bot works out the calls. That is
> the opposite of the split §7.1 and §7.2 use, and it is deliberate: every *decision* is still core's,
> but the diff is a comparison against live guild state that only the bot can see, and doing it in core
> would mean shipping the guild's whole role membership over the wire to compare it and shipping the
> answer back.
>
> **The membership diff is bounded per pass** (50 operations) and the remainder is reported, because
> each grant is its own API call under its own rate limit and an unbounded first pass on a large guild
> outlives its own request timeout — the one failure that leaves core not knowing what was applied. A
> non-zero remainder asks for another pass rather than waiting out the interval.
>
> **A failure is per-Team and never aborts the pass**, the same shape as §2.4's gate 3. A failed sync
> **keeps the refs it could not confirm**: a failure is core failing to confirm a channel, not learning
> it is gone, and clearing them would orphan a real channel and have the next pass build a second one
> beside it.
>
> The pass is **requested, not awaited**, by the Team reconciler — it makes Discord calls, and a roster
> sync must never be slowed, failed or held open by an integration hanging off it. It has its own
> 30-second debounce.
## Part 8 — Keeping the integration layer platform-agnostic ## Part 8 — Keeping the integration layer platform-agnostic
@@ -2709,10 +2832,60 @@ over-long forum post would not have arrived at all rather than arriving truncate
**Not done here.** No real Discord guild was involved — `channels.fetch` and a real `channel.send` **Not done here.** No real Discord guild was involved — `channels.fetch` and a real `channel.send`
are the two things this walk could not exercise, the same gap phase 7 recorded for `REST.put`. are the two things this walk could not exercise, the same gap phase 7 recorded for `REST.put`.
### Phase 9 — Discord: voice channels (`website` + `bot`) ### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19**
`team_integrations`, the threshold gate, the shared category, overwrite management with role `team_integrations`, the threshold gate, the shared category, **a per-Team role** (not overwrite
escalation above `voice_overwrite_max`, the grace-window lifecycle, and the stale-sync suspension. management with escalation — see §7.3's amendment), the grace-window lifecycle, and the stale-sync
suspension.
**Ships:** 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.
**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website` — the same
correction phases 7 and 8 made. `module-uo` is untouched and `MODULE_API_VERSION` does not move.
**Org-lead decisions (2026-08-19), all four settled before any code:** **roles always**, no overwrite
escalation · 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.
**Walked on the live rig before the PRs opened**, per the order phase 5 set — real MariaDB, the real
app, and a fake standing in for Discord that mounts the bot's real internal routes, so everything up
to the Discord API call was production code. 47 assertions.
#### What the walk proved, and the two defects it found
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; voice switched off leaving the channels standing;
and an admin removal working anyway, with a 404 for a Team that has none.
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 ("Error in results, duplicate field name `team_id`"). 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.
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 — got "a pass is already
running" and a panel saying nothing had happened, while the pass they triggered created their
channels. A pass in flight is now joined and its real outcome returned, as `reconcileNow` does.
#### Two things outside this phase that it had to work around
- **`npm run swagger` could not run at all on `edge`.** Phase 8 shipped a regex literal followed
directly by `.test(` in a route validator, which makes swagger-autogen's parser run away and the
process die out of memory. Hoisted to a const. 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 voice routes are therefore their own router file, mounted from `admin/index.js`.
- **`last_success_at` is written by MariaDB's `NOW()` and compared against JS `Date.now()`**, so an app
process and a database in different timezones skew every staleness judgement by the offset — which
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.
### Phase 10 — the capability layer (`website` + `docs`) ### Phase 10 — the capability layer (`website` + `docs`)