Compare commits

12 Commits

Author SHA1 Message Date
d8af33c805 Merge pull request 'docs(teams): cancel phase 10, and say what that leaves behind' (#164) from docs/teams-phase10-deferred into edge
Reviewed-on: #164
2026-08-19 06:18:10 +00:00
88cc49225a docs(teams): cancel phase 10, and say what that leaves behind
The org lead cancelled the capability layer on 2026-08-19, deferring it until a
second integration is wanted or it is asked for by name. Phase 11 is now the last
phase of the bet.

The same argument that put phase 10 last is the argument for not doing it yet:
with one integration built, the refactor would extract a capability surface from a
single implementation and have nothing to check the extraction against. It is
cheaper and better-informed the day a second platform exists, because that
platform is what proves which of the five capabilities the seam needs.

Three places pointed forward at it and now say what is true instead:

- Sec 7.2 and Sec 7.3 both justify the Admin -> Teams panels by "phase 10 makes
  the platform a registry lookup". The decision survives its reason: an operator
  should not have to know which platform is configured to find the panel, and that
  holds whether or not the registry is ever built.
- Sec 8.2 keeps the Matrix comparison and the capability table, with a note that
  no registry is built either. The research did its job by keeping core's calls
  phrased as eligibility questions rather than as Discord operations; what is
  absent is the indirection, so `discord` is named directly in the bridge, the
  voice provisioner and the command dispatcher.

The phase entry keeps its body rather than deleting it, because the argument for
the layer is what a future phase would start from.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:27:22 -05:00
6f1f406fe1 Merge pull request 'docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist' (#163) from docs/teams-phase9-voice into edge
Reviewed-on: #163
2026-08-19 05:15:43 +00:00
c87034d7fe docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist
Amends `TEAMS.md` §7.3 inline, marks phase 9 done in Part 12, and adds the
`team_integrations` row to `BACKEND_DESIGN.md`'s schema table. Pairs with
**website#159**.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:10:18 -05:00
d00ead09b6 Merge pull request 'docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold' (#162) from feature/teams-phase8-notifications-bridge into edge
Reviewed-on: #162
2026-08-19 01:32:22 +00:00
71f0b7ad90 docs(teams): phase 8 as built — the gate §7.2 could not check, and the key it could not hold
Amends §7.2 inline and marks phase 8 done in Part 12; adds the
team_integration_config row to BACKEND_DESIGN.md's schema table.

Two of the amendments are things the tree disproved rather than choices:

- §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY
  columns to NOT NULL, so `team_id NULL` is unrepresentable and the override
  mechanism has no base case. Confirmed against a real MariaDB (error 1048).
- §7.2's visibility gate has no data source on either side and cannot have one:
  the streams carry no visibility, a forum thread is members-only by
  construction rather than by a column, and core cannot see a channel's
  permissions. The gate becomes an attributed operator acknowledgement.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:25:41 -05:00
f5c121b02a Merge pull request 'docs(teams): phase 7 as built — five amendments to §7.1' (#161) from feature/teams-phase7-slash-commands into edge
Reviewed-on: #161
2026-08-19 00:15:15 +00:00
953f7fcd20 docs(teams): what the phase 7 rig walk proved, and the two defects it found
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 19:08:37 -05:00
c196d03d31 docs(teams): phase 7 as built — five amendments to §7.1
The command that proves the seam is the MODULE's `/guild`, not core's `/team`:
§7.1 was written before phase 3 settled that Teams is a contract primitive with
no core surface, and a core `/team` publishes the same invented noun that got
core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the
same reason — `/teams/:slug` does not exist.

The re-register nudge is its own bot endpoint rather than a ride on
`/internal/config`, whose body carries the decrypted bot token. `actor` carries
`role` beside `isStaff`, since a module with its own audience rungs cannot place
a caller from a boolean. And "deregistration is free" needed a second half: it
holds across the restart an uninstall asks for, not across the runtime toggle,
so liveness is asked at both the pull and the dispatch.

MODULE_API.md stops saying `registerSlashCommands` throws and documents it —
every member of 1.6.0 is live now.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:56 -05:00
30d964c090 Merge pull request 'docs(teams): queue the integration kit as phase 11, last before the cutover' (#160) from docs/teams-phase11-queue into edge
Reviewed-on: #160
2026-08-18 23:19:26 +00:00
9f90a99362 docs(teams): queue the integration kit as phase 11, last before the cutover
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).

One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.

Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.

The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.

Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:18:26 -05:00
703f0dbe67 Merge pull request 'docs(teams): phase 6 as built — four deviations and a ninth contract member' (#159) from docs/teams-phase6 into edge
Reviewed-on: #159
2026-08-18 23:09:39 +00:00
3 changed files with 514 additions and 29 deletions

View File

@@ -553,6 +553,8 @@ core's.
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
| `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_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 |
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
@@ -674,7 +676,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by |
|---|---|---|
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
Both are **core's**. An installed module's routes are in neither: they are in that module's own

View File

@@ -59,11 +59,9 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
that will implement it — chosen over leaving it absent so that a module written against the published
version fails at registration with a sentence explaining itself, rather than at whatever moment
someone first exercises the feature. Do not call it yet; do not treat its throw as a bug.
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
module may call any member of this version and get the behaviour documented below.
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
existing one is either the module claiming a mount or core notifying it; the closest precedent is
@@ -301,7 +299,7 @@ api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
api.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -492,10 +490,74 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case
omitted from a roster is indistinguishable, downstream, from that member having left — core would
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
throws with an error naming the phase that will. Present rather than absent so a module written
against the published version fails at registration with an explanation, instead of at the moment
someone first types the command.
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
belong to the module, live since phase 7 (TEAMS.md §7.1).
```js
api.registerSlashCommands([{
name: 'guild', // lowercase, 1-32, no dots
description: 'Show a guild on this shard', // 1-100 characters
options: [ // the restricted schema, below
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
],
access: 'everyone', // 'everyone' | 'linked' | 'staff'
async handler({ command, options, actor }) {
return { title, text, fields, url, ephemeral, notice } // every field optional
},
}])
```
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
a Discord handle, and this split is the reason a second platform could implement the same contract.
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
learns about the caller:
| field | |
| --- | --- |
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
| `platformUserId` | the caller's id on that platform |
| `guildId` | the platform community the command was run in, or `null` |
| `userId` | the site account, or `null` when the platform identity is not linked |
| `role` | that account's role — a module with audience rungs needs more than a boolean |
| `isLinked` | whether `userId` resolved |
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
operator-chosen slug.
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
permission default from it where the platform can express one; core re-checks it in the dispatcher on
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
boundary.
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
attachments, modals or component interactions — those are the features whose semantics do not survive
a second platform. A command needing them is a bot-side command, written in the bot.
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
registers the whole set in one call, so one bad option type would cost every command, the bot's own
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
types, and the ordering rule that a required option may not follow an optional one.
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
cannot see.
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
switches a module off does not leave a live handler behind it.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.

View File

@@ -100,11 +100,14 @@ module fills with anything live. Core does not grow an SSE stack for this.
exactly two shared-secret HTTP channels:
- **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js`
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
never throws, always returns `{ ok, status, data, error }`.
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since
phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns
`{ ok, status, data, error }`.
- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished*
internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
restart self-heals.
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and
`/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s
origin rather than taking a second variable naming the same host.
Slash commands are registered from a static array (`bot/src/discord/commands/index.js`) and pushed
with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set
@@ -1669,6 +1672,42 @@ is already being built there.
### 7.1 Slash-command registration
> **Amended 2026-08-18 (phase 7), as built.** Five changes, four of them forced by what the tree
> already looked like.
>
> **The example command is `/guild`, registered by module-uo, and core registers none.** §7.1 wrote
> `/team` as a core command against core's own Team rows. Phase 3 settled that **Teams is a contract
> primitive with no core surface** — core does not own the word for a Team, which is why four core
> Team pages were deleted — and a core `/team` publishes that same invented noun into a channel. The
> module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor
> resolver and the transport, and zero commands.
>
> **The deep link comes from `pageUrlTemplate`, because `/teams/:slug` does not exist.** The snippet
> below still says `${siteBaseUrl}/teams/${slug}`; there is no such page. A handler builds its own
> link — module-uo's is `/uo/guilds/{externalId}` — which is the same hole phase 6 found in the mail
> path and closed with the ninth contract member.
>
> **The re-register nudge is its own endpoint, `POST /internal/refresh-commands` on the bot**, not a
> ride on `/internal/config`. That body carries the DECRYPTED bot token: telling the bot that a
> module changed should not require reading a secret out of the database to say it.
>
> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean
> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own
> audience rungs needs to place the caller on them. It is the pair `projectRoster`'s viewer already
> carried (§3.3), not a new class of disclosure.
>
> **Deregistration needed a second half this section did not consider.** "A module that is gone is
> simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold
> for the runtime toggle: the registries have no removal path, so a module an operator disables would
> keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both
> the pull and the dispatch, and a disabled owner's command answers `unknown`.
>
> The envelope also gained **`notice`** — a private aside delivered beside a public answer, which is
> how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one
> reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's
> decision, not the handler's.
**Ownership, decided:** the registrant owns the **definition and the handler**; the handler runs **in
the website process** and returns a **response envelope**; the **bot owns every Discord-specific
concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
@@ -1737,10 +1776,20 @@ ephemeral "link your account for more" — see §9 answer 5.
### 7.2 Notifications bridge
> **Amended after building it (phase 8, 2026-08-18).** The shape below is what was designed; five
> things about it did not survive contact with the tree, and the amendments are inline. The largest
> is that **this section's own visibility gate has no data source and cannot have one** — see "The
> gate, as built" below. The phase entry in Part 12 carries the full list.
The same Team events as §6, delivered to a second consumer. Core emits each Team notification to an
internal fan-out with two subscribers: push (§6) and the integration bridge. **Not a second pipeline** —
one event, two deliveries.
> **As built**, there is no new fan-out object: `utils/teamNotify.js` already computed the recipient
> set once and handed the event to push and to email, so the bridge is a **third sink in that same
> file** rather than a subscriber to something new. `utils/teamBridge.js` is the sink; the file that
> calls it is unchanged in structure.
```sql
CREATE TABLE IF NOT EXISTS team_integration_config (
platform VARCHAR(32) NOT NULL, -- 'discord'
@@ -1753,10 +1802,34 @@ CREATE TABLE IF NOT EXISTS team_integration_config (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
> **This DDL cannot hold its own default row.** MariaDB coerces every `PRIMARY KEY` column to
> `NOT NULL`, so `team_id NULL` — the deployment-wide default that every override overrides — is
> unrepresentable, and the whole mechanism has no base case. **As built:** a surrogate `id` primary
> key, a generated `team_key INT AS (IFNULL(team_id, 0)) STORED` carrying
> `UNIQUE KEY (platform, team_key)`, and a real `FOREIGN KEY (team_id) … ON DELETE CASCADE` that the
> original had no room for — without it a deleted Team leaves its configuration behind for whichever
> Team next lands on that id. The generated-column trick is the one `teams.active_key` and
> `content_reports.open_marker` already use. Three further columns carry the gate: `members_ack`,
> `members_ack_by` and `members_ack_at`.
Admin-configurable per event type, globally and per Team (a per-Team row overrides the `team_id IS
NULL` default). Delivered via `POST /internal/team-notify` on the bot, best-effort, never throwing —
identical to `announce` and `mod-reverse`.
> **`announce` and `mod-reverse` are not the same thing.** `announce` rides `announce_jobs` with
> backoff, retries and a per-leg retry button in the admin panel; `mod-reverse` is a one-shot call
> that records failure and stops. **The bridge is one-shot.** A news post is a durable artifact whose
> Discord copy is expected to exist; a Team notification is the moment it describes, and one that
> arrives twenty minutes late is worse than one that never arrives. A second job table and a second
> worker is a great deal of machinery to buy the opposite outcome.
>
> **The admin surface is its own panel under Admin → Teams**, beside the forum settings, and not an
> extension of the Discord Bot panel — a second integration would make the platform a registry lookup
> (§8.2), and what should change then is what fills the panel, not where it is. That holds whether or
> not the capability layer is ever built; Phase 10, which would have built it, is cancelled. It is **admin-only**, the one such corner of a
> staff-wide router: configuring where a Team's content leaves the site for is deployment
> configuration rather than the §2.9 kind of decision a moderator files a request for.
**A Discord message carries content; a push tickle does not.** Stated explicitly because the two look
like the same event and are not: ntfy is an untrusted relay reached by an unguessable topic, so the
tickle is content-free by design; the Discord server is an operator-configured, trusted destination
@@ -1764,11 +1837,76 @@ where an empty "something happened, go look" message would be useless. What *is*
allowlist discipline — an event is bridged only if its `visibility` is `public`, or its destination
channel is configured for a members-only Team context.
#### The gate, as built
**Neither half of that last sentence has a data source, and neither can have one.**
- The four `team.*` streams carry **no `visibility`**. Only `team_activity` rows do, and a
notification is not an activity row.
- Forum threads have **no public/members column**, because a forum is members-only by construction —
every thread in it sits behind `team_forum_grants`. So §7.2's own example configuration,
`['team.announcement','team.forum.post']`, names exactly the two events that can never be public.
- Core **cannot see a Discord channel's permissions**, so "configured for a members-only Team
context" is not a fact core can check. Only the operator can see it.
So the gate becomes an **attributed acknowledgement**: enabling an event that carries members-only
content requires an explicit confirmation that the destination channel is restricted to that Team's
members, recorded with who gave it and when — the same shape `teams_forum_uploads_ack` uses for the
image policy (§5.5.5). Four properties make it a gate rather than a checkbox:
1. **It is a precondition, not a preference.** A save that would enable a members-only event without
it is refused **422**, not accepted-and-quietly-degraded. A configuration that silently does less
than it says is worse than one that will not save.
2. **It is re-asked at delivery**, not only at the save, so a row that loses the tick — an admin
repoints it, or a future change reclassifies a stream it already carries — stops carrying those
events immediately rather than at the next save.
3. **Changing the channel clears it.** An acknowledgement is about a *destination*; it cannot survive
the destination changing underneath it, or an operator could confirm a private channel and then
repoint the row at a public one while keeping the permission granted for somewhere else.
4. **A roster-only bridge needs no acknowledgement at all**, and a *disabled* row may carry forum
events without one — drafting a configuration is not publishing to a channel, and a dialog that
appears on saves that did not need it is one people learn to click through.
Two smaller consequences of the same asymmetry:
- **The author exclusion stops at the channel.** Push and email both subtract the post's author; the
bridge does not. Excluding is a per-recipient idea, and a channel has no per-recipient anything —
suppressing the message because the author reads that channel would deprive everyone else in it.
- **A roster event carries a count and never a name.** The sync notifies once per run rather than
once per member (§6.2), so a count is all the caller holds. It is also all it should say: a
character name is game-sourced text screened for a *page*, not for a channel.
### 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
(`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 +
`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
@@ -1778,10 +1916,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
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).
Counted on **linked** members only, since an unlinked member cannot be granted anything on Discord
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:
- A voice channel holds **no message history**, so deletion destroys nothing recoverable. The
@@ -1797,34 +1983,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"
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
reconciler **skips entirely** — no creation, no deletion, no overwrite changes. A voice channel is never
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
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites',
role_ref VARCHAR(64) NULL,
role_ref VARCHAR(64) NULL, -- the Team's role: the grant itself
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL,
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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
) 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
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
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
@@ -1879,6 +2114,14 @@ installed.
**No Matrix implementation is built.** §8 is research to shape the Discord contract, exactly as the
brief asks.
> **And no capability registry is built either** (2026-08-19). Phase 10 would have extracted the one
> above from Phases 7–9's Discord code; it is cancelled and deferred until a second integration is
> wanted. Everything in §8 stays as it is — the comparison is what makes the *shape* of the Discord
> work defensible, and it did its job by keeping core's calls phrased as eligibility questions rather
> than as Discord operations. What is not there is the indirection: `discord` is named directly in the
> bridge, the voice provisioner and the command dispatcher, and a second platform is a phase, not a
> configuration change.
---
## Part 9 — The explicit answers
@@ -2165,6 +2408,9 @@ makes §0.1's roster possible:
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard
serial dependency in the list.
**Phase 10 is cancelled** (org lead, 2026-08-19), deferred until a second integration is wanted or
it is asked for by name — see its own entry. Phase 11 is therefore the last phase of the bet.
**Phase 11 is the exception to "independently shippable", and it is last on purpose** (org lead,
2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams
expands the contract, so the book is the last thing owed before `edge` becomes `main`. It is also the
@@ -2485,31 +2731,206 @@ them as toggles automatically, but nothing here builds a Team screen or a deep-l
app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
oversight.
### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18**
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
command through it.
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
through it.
**Ships:** a working `/team`, and the seam a module needs for its own commands.
**Ships:** a working `/guild`, and the seam a module needs for its own commands.
### Phase 8 — Discord: notifications bridge (`website` + `bot`)
**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace
inside `website`, so the bot half lands in the same PR as the server half; `module-uo` joins instead,
because the command that proves the seam belongs to the module and not to core (see the amendment at
the head of [§7.1](#71-slash-command-registration)).
`team_integration_config`, the internal fan-out with push and bridge as two consumers,
`POST /internal/team-notify`, the admin per-event configuration.
**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing
in for Discord. It proved the audience rung holding over the chat surface (guilds gated to `staff`:
anonymous and linked-player refused, linked admin served, same command), the Discord provider
resolving by `kind` on a deployment whose provider slug is `my-discord`, a banned account resolving as
unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running,
and the pull emptying plus dispatch answering `unknown` for a module switched off at runtime.
### Phase 9 — Discord: voice channels (`website` + `bot`)
**It found two defects, both folded in.** A refusal was posted PUBLICLY — ephemerality is fixed at the
deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not
shown to your account" announced a member's access level to the channel. And the refusal offered
linking on a shard gated to `staff`, where linking reaches `player` and stops.
`team_integrations`, the threshold gate, the shared category, overwrite management with role
escalation above `voice_overwrite_max`, the grace-window lifecycle, and the stale-sync suspension.
**The bot got its first test harness.** It had no `test` script and no tests at all — CI ran
`npm ci --prefix bot` and nothing else — which was defensible while the bot only wired up its own
static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing
registration and runs the interaction path, and phases 8 and 9 add more. `bot/test/` and a
`bot-tests` job replace `bot-install`.
### Phase 10 — the capability layer (`website` + `docs`)
### Phase 8 — Discord: notifications bridge (`website` + `docs`) — **DONE 2026-08-18**
`team_integration_config`, the bridge as a third sink beside push and email, `POST
/internal/team-notify`, and the admin per-event configuration.
**Ships:** a Team's forum posts, announcements and roster changes arriving in a Discord channel the
operator chose, per Team or deployment-wide.
**ONE code repo, not the plan's `website` + `bot`.** `bot` is a workspace inside `website`, the same
correction phase 7 made — but unlike phase 7 nothing here belongs to a module, so `module-uo` is
untouched: the four streams are core's own and the bridge reads core's own forum. `MODULE_API_VERSION`
does not move.
**Walked on the live rig before the PRs opened**, per the order phase 5 set.
#### Five things the tree disagreed with §7.2 about
1. **`PRIMARY KEY (platform, team_id)` cannot hold the default row.** MariaDB coerces every primary
key column to `NOT NULL`, so `team_id NULL` — the deployment-wide default, and the base case of the
whole override mechanism — is unrepresentable. As built: a surrogate `id`, a generated
`team_key AS (IFNULL(team_id, 0)) STORED` in the unique key, and the foreign key the original DDL
had no room for. Same idiom as `teams.active_key` and `content_reports.open_marker`.
2. **The visibility gate has no data source on either side, and cannot have one.** §7.2 bridges an
event only if "its `visibility` is `public`, or its destination channel is configured for a
members-only Team context". The four `team.*` streams carry no visibility — only `team_activity`
rows do, and a notification is not an activity row — and forum threads have no public/members
column because a forum is members-only by construction, everything in it sitting behind
`team_forum_grants`. So §7.2's own example config, `['team.announcement','team.forum.post']`,
names exactly the two events that are never public. Nor can core see a Discord channel's
permissions to check the other half.
**As built: an attributed operator acknowledgement**, `members_ack` / `members_ack_by` /
`members_ack_at`, in the shape `teams_forum_uploads_ack` already uses. Enabling a members-only
event without it is refused **422** rather than dropped at delivery, because a configuration that
silently does less than it says is worse than one that will not save. It is re-asked at delivery as
well as at the save, so a row that loses the tick stops carrying those events at once — and
**changing the channel clears it**, since an acknowledgement is about a destination and cannot
survive the destination changing underneath it.
3. **"Identical to `announce` and `mod-reverse`" names two different things.** `announce` rides
`announce_jobs` with backoff, retries and a per-leg retry button; `mod-reverse` is one-shot. The
bridge is **one-shot**: a news post is a durable artifact whose Discord copy is expected to exist,
while a Team notification is the moment it describes, and a message arriving twenty minutes after
the conversation moved on is worse than one that never arrives. A bot that is down drops it, which
is the deal the push tickle already takes.
4. **The author exclusion stops at the channel.** Push and email both subtract the author; the bridge
does not. Excluding is a per-recipient idea and a channel has no per-recipient anything —
suppressing the message because the author happens to read that channel would deprive everyone
else in it.
5. **A roster event has a count and no name.** The sync notifies once per run rather than once per
member (§6.2), so a count is all the caller holds; it is also all it should say. `memberJoined`
grew an optional `{ count }` **for the bridge only** — a channel has no app on the other end to
pull anything after a content-free nudge — and the tickle beside it is unchanged.
#### Where the admin surface lives, and why it is not in the Discord panel
Its own panel under **Admin → Teams**, beside the forum settings, rather than an extension of
`DiscordBotAdmin`. A second platform would replace "Discord" with whatever a capability registry
declares (§8.2); what should change then is what fills the panel, not where an operator goes to find
it. Phase 10 would have built that registry and is cancelled — which changes nothing here, because
the reason this panel is not inside the Discord one is that an operator should not have to know which
platform is configured to find it. It is the one
**admin-only** corner of a staff-wide router: this is not the §2.9 kind of decision a moderator files
a request for, it is deployment configuration, and it sits with the role that already holds the bot
token.
#### What the rig proved, and the two defects it found
Real ServUO + real sidecar (protocol 4) + the app with module-uo installed, with a fake standing in
for Discord. It proved the default row governing a Team with no row of its own, a per-Team override
beating it (including an override that switches the bridge OFF for one Team while the default stays
on), the 422 on an unacknowledged forum bridge, the acknowledgement clearing on a repoint, forums
switched off silencing the bridge along with the push, and a bot that is down costing the forum reply
nothing.
**Both defects came out of tests written against the rig's shapes.** A re-acknowledgement given for a
NEW channel kept the OLD attribution — the column was already 1, so "freshly acknowledged" read false
and the row went on naming whoever vetted the previous destination, which is the entire audit value of
the column. And the embed description was clamped to Discord's limit **before** the heading was
prepended, producing a description one heading over the limit; discord.js rejects that outright, so an
over-long forum post would not have arrived at all rather than arriving truncated.
**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`.
### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19**
`team_integrations`, the threshold gate, the shared category, **a per-Team role** (not overwrite
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`) — **CANCELLED 2026-08-19**
> **Not built, and not scheduled.** The org lead cancelled this phase after Phase 9, deferring it
> until a second integration is actually wanted or it is asked for by name. What follows is what it
> would have done, kept because the argument for it survives its cancellation.
Refactor Phases 7–9's Discord code behind the declared-capability registry (§8.2) and prove it by
rendering the admin UI from the declaration rather than from a hardcoded "Discord" assumption. Last on
purpose: extracting a capability surface from one working implementation is honest; designing it before
one exists is speculation.
**Why cancelling it costs little.** The same argument that put it last is the argument for not doing it
yet: with exactly one integration built, the refactor would extract a capability surface from a single
implementation and have nothing to check the extraction against. §8.2's Matrix column is research, not
a second implementation, and a registry whose only consumer is the thing it was extracted from is a
layer of indirection that has not yet been paid for. The work is cheaper *and* better-informed the day
a second platform exists, because that platform is what proves which of the five capabilities the
seam actually needs.
**What it leaves behind, stated so nobody has to re-derive it.** Phases 7–9 name Discord directly —
in the bridge config (`team_discord_config`), the voice provisioner, the slash-command dispatcher and
their admin panels. That is not a defect and no code is placed differently in anticipation of a layer
that may never come. Two decisions were made *for* this phase, and both stand on their own:
the notification bridge and the voice panel live under **Admin → Teams** rather than inside the
Discord Bot panel (§7.2, §7.3), because where an operator goes to find them should not depend on which
platform fills them; and core's calls are already phrased as questions about eligibility — *these user
ids are eligible for Team 3* — rather than as instructions about overwrites. A second integration
would be a new phase against that surface, not a rescue of this one.
### Phase 11 — the integration kit (`integration-kit`) — **the last phase before the cutover**
The kit is the instruction book for putting a *different* game on this platform, written for an