Compare commits

...

9 Commits

Author SHA1 Message Date
2adf397da4 docs(teams): phase 11 as built, and what the rig walk found
The phase ran before the cutover as Part 12 planned, and found what it was meant
to: the inverted slot direction worked for module-uo and nobody else. Recorded
beside the phase entry, with the org lead's two decisions of the day - core
offers a contribution rather than naming a slot, and the template grows a real
provider rather than a snippet.

Also records the live-rig walk and the one defect it found that no test could:
PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in
silence, so every page built from the kit's template had been rendering its
heading with nothing under it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:31:52 -05:00
d10f5809b3 docs(modules): core offers a contribution, never a slot name
Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule
the eighth and ninth members were given - and it is a correction rather than an
addition.

As first written, the inverted slot direction had core fill three literal
uo.guild.* names. That worked for module-uo and silently did nothing for anyone
else: a module declaring clan.detail under its own id got an empty page and no
error, because "a fill for a slot nobody declared is not an error" is exactly
the rule that makes an unknown name invisible. It also put a module identifier
inside core, in string literals the Sec 5.2 checker masks by construction.

Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three
contributions core offers - team.activity, team.forum, team.notify - as a
table, with the rules that follow from the direction: the member is optional, a
slot that asks for nothing stays empty, more than one slot may ask for the same
contribution, and asking for one core does not offer THROWS at the declaration
rather than rendering empty forever.

TEAMS.md's two accounts of the inversion (Part 3's supersession note and the
phase 3 amendment) say the same thing.

Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in
phase 3 and three places still said eight.

Found by phase 11 while writing the chapter that teaches this shape to an
audience outside this org.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:16:07 -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
3 changed files with 576 additions and 58 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

@@ -37,8 +37,16 @@ module chunk evaluates, which is earlier than any network round trip could answe
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an
> optional `{ core }` naming which of core's contributions belongs in the declared place, and core
> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by
> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction
> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the
> inverted direction worked for exactly one module and silently did nothing for any other, which the
> integration kit found while trying to teach it to an audience outside this org.
>
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
>
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
@@ -56,14 +64,12 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a
> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own
> the word for one, so the module that owns the vocabulary owns the page. In their place,
> `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.
> `registry.declareModuleSlot(id, name, { core })` 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 +307,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 +498,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.
@@ -988,7 +1058,7 @@ props is a **major** one, because that breaks a call already written. Adding an
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
anything.
The kit is those **eight exports** — five rows, because `PageState` contributes three. An earlier
The kit is those **nine exports** — six rows, because `PageState` contributes three. An earlier
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
@@ -1231,10 +1301,11 @@ forced it is worth stating because it will recur:
> only core can resolve — are contributed to it.
```js
// In the module's entry chunk, at registration time:
registry.declareModuleSlot(ID, 'uo.guild.header')
registry.declareModuleSlot(ID, 'uo.guild.detail')
registry.declareModuleSlot(ID, 'uo.guild.forum')
// In the module's entry chunk, at registration time. The second argument names
// which of CORE's contributions belongs in that place:
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// In the module's page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
@@ -1242,6 +1313,33 @@ registry.declareModuleSlot(ID, 'uo.guild.forum')
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
```
**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and
the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which
worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail`
under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an
error" is exactly the rule that makes an unknown name invisible. It also put a module identifier
inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and
could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`.
So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there:
| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's |
| --- | --- | --- |
| `team.activity` | the Team activity feed | only core can resolve the public/members split on it |
| `team.forum` | the Team forum panel | membership and manual grants are core's rules |
| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team |
`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping
empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that
asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the
module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a
version skew, and the alternative failure is a page that renders empty forever with nothing logged.
**Adding a contribution is a minor bump**; removing one is major.
More than one slot may ask for the same contribution and each gets it. Core has no reason to care how
many places a module wants its feed in, and refusing the second would be core making a layout decision
on a page it does not own.
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
guild page — core fills them with the Team notification control, the activity feed and the Team forum
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
@@ -1256,25 +1354,25 @@ readable at the fill site.
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's
bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of
these the slot does not exist. Core registers its intent (`fillModuleSlot`, core-only) and
these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
render.
**A fill for a slot no installed module declares is a no-op, never an error.** The declaring module is
simply not installed, which is the ordinary case on any deployment — the exact mirror of an unfilled
slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
**A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is
the ordinary case on any deployment — the exact mirror of an unfilled slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
name is always a typo or a version skew, because core declares before any module can name one.
**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is
skipped. That is deliberate: the module owns the page.
**`Slot` is the eighth member of the UI kit** (§3.4) for this. A module could not render one of these
**`Slot` is the ninth member of the UI kit** (§3.4) for this. A module could not render one of these
otherwise, and reimplementing it would mean a second error boundary with different behaviour — which
matters more here than anywhere else in the kit, because the thing being contained is *core's* content
failing inside the *module's* page.
`declareModuleSlot` is on the `registry` object handed to modules. `fillModuleSlot` and
`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is.
`declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills`
and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7
slot is.
---

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
@@ -827,18 +830,20 @@ named for a *place* and never for a meaning):
> **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what
> changed. They assumed core rendered the Team page; core renders no Team page. The replacement is
> `registry.declareModuleSlot(id, name)` — a **module** declares a place on its own page, namespaced
> under its own id, and **core** fills it:
> `registry.declareModuleSlot(id, name, { core })` — a **module** declares a place on its own page,
> namespaced under its own id, naming which of core's contributions goes there, and **core** offers it:
>
> | Slot | Declared by | Rendered in | Filled by core with | Props |
> | --- | --- | --- | --- | --- |
> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` |
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | the Team forum (Part 5) — added in phase 4 | `{ externalId, moduleId }` |
> | `uo.guild.detail` | `module-uo` | its guild detail page | `team.activity` — the Team activity feed (§4.3) | `{ externalId, moduleId }` |
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | `team.forum` — the Team forum (Part 5), added in phase 4 | `{ externalId, moduleId }` |
>
> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk,
> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module
> declares is a no-op, not an error — the mirror of an unfilled slot rendering nothing. `Slot` becomes
> the eighth member of the shared UI kit so a module renders the place with core's own error boundary,
> Core's contributions are applied at MOUNT, not eagerly: core's bundle evaluates before every module
> chunk, so when core offers one, no module-declared slot exists yet. A contribution nothing asks for
> is a no-op, not an error — the mirror of an unfilled slot rendering nothing. **Core names the
> contribution and never the slot** (amended phase 11, inside 1.6.0: as first built it filled the
> literal names above, which reached `module-uo` and no other game). `Slot` becomes
> the ninth member of the shared UI kit so a module renders the place with core's own error boundary,
> which matters here because the thing being contained is CORE's content failing inside the MODULE's
> page.
>
@@ -1669,6 +1674,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 +1778,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 +1804,33 @@ 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 — 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
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 +1838,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 +1917,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 +1984,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
@@ -2301,13 +2537,16 @@ guild called "Admin" cannot put an official-looking page on the site.
>
> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview`
> and `team.member.row` assumed core rendered the page. They are replaced by
> `registry.declareModuleSlot(id, name)`: a **module** declares a place on its own page, namespaced
> under its own id, and **core** fills it. `module-uo` declares `uo.guild.detail`; core fills it with
> the activity feed, because only core can resolve whether a viewer is inside the Team and the
> public/members split is a security boundary. Core's fills are applied at mount rather than eagerly —
> core's bundle evaluates before every module chunk, so at the moment core registers a fill the slot
> does not exist yet. `Slot` joins the shared UI kit as its eighth member so the module renders the
> place with core's own error boundary.
> `registry.declareModuleSlot(id, name, { core })`: a **module** declares a place on its own page,
> namespaced under its own id, and names which of core's contributions belongs there. `module-uo`
> declares `uo.guild.detail` and asks for `team.activity`; core offers the activity feed, because only
> core can resolve whether a viewer is inside the Team and the public/members split is a security
> boundary. **Core names the contribution, never the slot** — amended in phase 11, inside 1.6.0, after
> the integration kit found that the literal-name version worked for one module and silently did
> nothing for any other. Core's contributions are applied at mount rather than eagerly — core's bundle
> evaluates before every module chunk, so at the moment core offers one, no module-declared slot exists
> yet. `Slot` joins the shared UI kit as its ninth member so the module renders the place with core's
> own error boundary.
>
> **A module names a Team in its own vocabulary**, so `GET /public/teams/by-external/:moduleId/:externalId`
> is added: core's row id and slug are core-internal and handing them to a module is how a module ends
@@ -2485,23 +2724,173 @@ 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 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`) — **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`)
@@ -2543,6 +2932,35 @@ members and has never mentioned `registerNotificationStreams`, `registerAnnounce
The question this phase answers is "did the teaching path change", and the answer is yes in two
places and no everywhere else.
> **Amended 2026-08-19, as built.** The phase ran before the cutover, as planned, and it found what it
> was meant to find. Four notes.
>
> **The inverted slot did not work for anyone but `module-uo`, and the kit is what proved it.** Core
> filled three literal `uo.guild.*` names, so a second game's module declared its places under its own
> id and core filled none of them — an empty page, no error, nothing logged, because *"a fill for a
> slot nobody declared is not an error"* is exactly the rule that hides an unknown name. Settled by the
> org lead the same day: **core offers a CONTRIBUTION and never names a slot**, amended into 1.6.0 in
> place since it has only ever been on `edge` (website#160, Module-uo#15, docs#165). The kit could not
> have taught the shape honestly without this, which is the argument for having written the book
> before the cutover rather than after it.
>
> **The template grew a real provider rather than a snippet** (org lead, 2026-08-19). It registers
> `registerTeamProvider` over two tables of its own, declares three slots on a clan page, and serves
> its own `/clans` — deliberately not `/teams`, which is core's and which the loader would refuse. The
> guards that matter are the ones a reader would otherwise omit: an unreachable game refuses rather
> than reporting no clans, an empty roster is refused unless the game says the clan is empty, and one
> audience rule serves both `projectRoster` and the module's own page.
>
> **It was walked on a live rig before the PRs opened** — real MariaDB, the real loader, a browser.
> Core reconciled two Teams out of the provider on the first boot, `/public/teams/<slug>/members`
> answered `projected: true`, and the clan page rendered core's activity feed and forum in the slots
> the module declared. `module-uo`'s guild page was walked on the same core and is unchanged. The walk
> found one defect no test could: `PageHeader` takes `lead`, not `subtitle`, and React drops an unknown
> prop silently — so every page built from the template had rendered its heading with nothing under it
> since the template was written.
>
> **Phase 10's cancellation makes this the last phase**, and nothing in it changed as a result.
**Then the two mechanical lines:** `ci/core-ref.json`'s sha moves to the cutover commit and
`template/module.json`'s `coreApi` becomes `^1.6.0`, which puts `scripts/checkCoreApi.js` back to
green. That check is an **equality**, and its going red is the mechanism rather than a bug — a