Compare commits

..

1 Commits

Author SHA1 Message Date
12bd24a973 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:17:26 -05:00
3 changed files with 21 additions and 295 deletions

View File

@@ -553,7 +553,6 @@ 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_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_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 |
| `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`,
@@ -675,7 +674,7 @@ are authoritative, and they answer different questions:
| Artifact | Source of truth for | Generated by | | 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.** 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/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/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations | | `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 Both are **core's**. An installed module's routes are in neither: they are in that module's own

View File

@@ -59,9 +59,11 @@ 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 > `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. > fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still **The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
module may call any member of this version and get the behaviour documented below. 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.
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every **`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 existing one is either the module claiming a mount or core notifying it; the closest precedent is
@@ -299,7 +301,7 @@ api.registerNotificationStreams(streams)
api.registerAnnounceLeg({ leg, label, dispatch, classify }) api.registerAnnounceLeg({ leg, label, dispatch, classify })
api.registerPostHook({ onSaved, onDeleted }) api.registerPostHook({ onSaved, onDeleted })
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0 api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0 api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
api.onBoot(async (ctx) => {}) api.onBoot(async (ctx) => {})
api.onShutdown(async () => {}) api.onShutdown(async () => {})
``` ```
@@ -490,74 +492,10 @@ 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 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. mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both **`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
belong to the module, live since phase 7 (TEAMS.md §7.1). 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
```js someone first types the command.
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. **`onBoot(fn)` / `onShutdown(fn)`** — §2.5.

View File

@@ -100,14 +100,11 @@ module fills with anything live. Core does not grow an SSE stack for this.
exactly two shared-secret HTTP channels: exactly two shared-secret HTTP channels:
- **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js` - **app → bot**, `utils/botInternalClient.js` → `bot/src/internal/internal.routes.js`
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since (`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
phase 7 `/internal/refresh-commands`), 4s timeout, never throws, always returns never throws, always returns `{ ok, status, data, error }`.
`{ ok, status, data, error }`.
- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished* - **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 internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and restart self-heals.
`/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 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 with `REST.put(Routes.applicationGuildCommands(...))` on ready (`discordManager.js:25`) — a **whole-set
@@ -1672,42 +1669,6 @@ is already being built there.
### 7.1 Slash-command registration ### 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 **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 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 concern** — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
@@ -1776,20 +1737,10 @@ ephemeral "link your account for more" — see §9 answer 5.
### 7.2 Notifications bridge ### 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 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** — internal fan-out with two subscribers: push (§6) and the integration bridge. **Not a second pipeline** —
one event, two deliveries. 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 ```sql
CREATE TABLE IF NOT EXISTS team_integration_config ( CREATE TABLE IF NOT EXISTS team_integration_config (
platform VARCHAR(32) NOT NULL, -- 'discord' platform VARCHAR(32) NOT NULL, -- 'discord'
@@ -1802,33 +1753,10 @@ CREATE TABLE IF NOT EXISTS team_integration_config (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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 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 — NULL` default). Delivered via `POST /internal/team-notify` on the bot, best-effort, never throwing —
identical to `announce` and `mod-reverse`. 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 — phase 10 makes the platform a registry lookup, and what should
> change then is what fills the panel, not where it is. 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 **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 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 tickle is content-free by design; the Discord server is an operator-configured, trusted destination
@@ -1836,45 +1764,6 @@ 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 allowlist discipline — an event is bridged only if its `visibility` is `public`, or its destination
channel is configured for a members-only Team context. 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 ### 7.3 One voice channel per Team
**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
@@ -2596,118 +2485,18 @@ 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 app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
oversight. oversight.
### Phase 7 — Discord: slash commands (`website` + `module-uo` + `docs`) — **DONE 2026-08-18** ### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
`api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's `api.registerSlashCommands`, `/internal/commands` + `/internal/commands/dispatch`, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
through it. command through it.
**Ships:** a working `/guild`, and the seam a module needs for its own commands. **Ships:** a working `/team`, and the seam a module needs for its own commands.
**THREE repos, not the plan's `website` + `bot` + `docs` — `bot` is not a repo.** It is a workspace ### Phase 8 — Discord: notifications bridge (`website` + `bot`)
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)).
**Walked on the live rig before the PRs opened** — real ServUO + real sidecar (protocol 4) + the app `team_integration_config`, the internal fan-out with push and bridge as two consumers,
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing `POST /internal/team-notify`, the admin per-event configuration.
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.
**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.
**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 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`. Phase 10 replaces "Discord" with whatever the capability registry declares; what
should change then is what fills the panel, not where an operator goes 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` + `bot`) ### Phase 9 — Discord: voice channels (`website` + `bot`)