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>
This commit is contained in:
@@ -674,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.** 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 |
|
| `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
|
||||||
|
|||||||
@@ -59,11 +59,9 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
|
|||||||
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to
|
> `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.
|
||||||
|
|
||||||
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
|
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
|
||||||
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
|
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
|
||||||
that will implement it — chosen over leaving it absent so that a module written against the published
|
module may call any member of this version and get the behaviour documented below.
|
||||||
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
|
||||||
@@ -301,7 +299,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([...]) // 1.6.0, throws until phase 7
|
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
||||||
api.onBoot(async (ctx) => {})
|
api.onBoot(async (ctx) => {})
|
||||||
api.onShutdown(async () => {})
|
api.onShutdown(async () => {})
|
||||||
```
|
```
|
||||||
@@ -492,10 +490,74 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case
|
|||||||
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
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)`** — declared in API 1.6.0 and **not yet implemented**: calling it
|
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
|
||||||
throws with an error naming the phase that will. Present rather than absent so a module written
|
belong to the module, live since phase 7 (TEAMS.md §7.1).
|
||||||
against the published version fails at registration with an explanation, instead of at the moment
|
|
||||||
someone first types the command.
|
```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.
|
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||||
|
|
||||||
|
|||||||
@@ -100,11 +100,14 @@ 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`), 4s timeout,
|
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`, and since
|
||||||
never throws, always returns `{ ok, status, data, error }`.
|
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*
|
- **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.
|
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
|
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
|
||||||
@@ -1669,6 +1672,42 @@ 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
|
||||||
@@ -2485,13 +2524,24 @@ 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` + `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
|
`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
|
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
|
||||||
command through it.
|
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.
|
||||||
|
|
||||||
|
**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)).
|
||||||
|
|
||||||
|
**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` + `bot`)
|
### Phase 8 — Discord: notifications bridge (`website` + `bot`)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user