docs(teams): Team core, MODULE_API 1.6.0, and what phase 2 disproved

Documents phase 2 of docs/website/TEAMS.md across the three files that had to
change, and records the five places building it disagreed with the design.

## MODULE_API.md — 1.6.0

The Team surface becomes contract: `api.registerTeamProvider(...)`,
`ctx.teams.publish` / `ctx.teams.reconcile` / `ctx.teams.activity.push`,
`api.registerSlashCommands(...)`, and the two client slots. Additions only, so
minor; module-uo's `coreApi: "^1.3.0"` still resolves.

Per the org lead's decision, one 1.6.0 covers the whole surface rather than a
minor per phase -- so the document names the phase against each member, and the
two that cannot work yet are marked as present-and-throwing rather than left to
be discovered at runtime.

`registerTeamProvider` gets the fullest treatment because it is the first
registration where core calls the MODULE and waits for an answer. The envelope,
the 10-second budget and the refusal semantics are all contract, not
implementation: they are how a module says "I cannot answer" without core hearing
"there is nothing". `ctx.teams` is documented as push-only, with the reason there
is no reader — a module answers questions about Teams, it does not ask them.

## BACKEND_DESIGN.md

The six Team tables, the rename rule, the active-only uniqueness encoding, the
per-column account-deletion decisions, and all eighteen routes across the three
tier tables.

Two entries there exist to stop a future reader "fixing" them: why
`team_forum_grants` does not use the obvious generated column, and why the two
columns TEAMS.md never mentioned have to exist.

## TEAMS.md — five amendments, marked as amendments with their date

  - **§2.5's SQL and §2.10's decision cannot both hold.** MariaDB refuses ON
    DELETE SET NULL on a base column of a stored generated column (1901), so
    §2.5's `active_user` forces the CASCADE that §2.10 exists to prevent. §2.10
    wins; the marker is re-encoded for identical semantics.

  - **`team_forum_grants` lands in phase 2**, so the four-path resolver is written
    once and its non-contamination tests are real.

  - **Two columns the document did not contemplate**, both serving §2.4's gates:
    `roster_synced_at`, because sync state is per MODULE and gate 3 leaves one
    Team behind while the others sync; and `members_empty_since`, gate 4's
    per-Team quarantine.

  - **`leader` on the member shape is not path 2.** Taking §2.3 and §2.5 both
    literally gives one column two writers, and the roster writes first — so a
    refused `getTeamLeaders()` silently demoted everyone. Found by its own test.

  - **§2.8.2's matcher needed two narrow widenings**, both real impersonation
    vectors the whole-word rule missed: a term matches a name word's singular
    ("Guild of Moderators"), and a run of single-letter words is compared joined
    ("G.M."). Neither re-admits substring matching.

Pairs with website (Teams phase 2) and Module-uo (the provider).

Refs docs/website/TEAMS.md Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:32:21 -05:00
parent 11696d14e3
commit 680a4866ac
3 changed files with 201 additions and 1 deletions

View File

@@ -26,13 +26,31 @@ here extends the contract first, in this file, before the module is written agai
Core exports a single integer-major semver string from `server/src/modules/version.js`:
```js
const MODULE_API_VERSION = '1.5.0'
const MODULE_API_VERSION = '1.6.0'
```
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.6.0 — Teams, the whole surface.** Seven additions, no removals and no changed signature, so minor;
`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` ·
`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`.
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three
are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with
an error naming the phase that will implement them — chosen over leaving them 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 them yet; do not treat a
throw as a bug.
**`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
`registerAnnounceLeg`'s `dispatch`, which is why this is modelled on it. That direction is what makes
the envelope, the 10-second budget and the refusal semantics contract rather than implementation —
they are how a module says "I cannot answer" without core hearing "there is nothing".
**1.5.0 — Phase 5 slice 3, the page shell.** `PublicLayout` takes an optional **`shell`** prop —
`'narrow'`, `'mid'` or `'wide'` — that renders the page-body wrapper core's own pages have always
written by hand (§3.4). Found by the acceptance run in
@@ -181,6 +199,28 @@ module-uo does not need is on the list.
| `ctx.middleware.rateLimit` | `(options) => middleware` | `middleware/rateLimit` | the market search (1.1.0) |
| `ctx.middleware.accountChangeLimiter` | middleware | `middleware/rateLimit` | `player/shard.router` (1.1.0) |
| `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks |
| `ctx.teams.publish` | `(event) => Promise<void>` | `model/teams/teamSync` | the Team provider's module (1.6.0) |
| `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`**throws until the Team activity feed lands** | — | (1.6.0, declared) |
**`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers*
questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a
`getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the
module already holds, in its own store.
Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership
change visible at once; `reconcile` is a debounced *request* that returns immediately and never
rejects. Correctness comes from reconciliation either way, so neither can make a module's own call
site slow or turn a background failure into the module's error.
The six event kinds `publish` accepts are `team.created`, `team.disbanded`, `team.member.added`,
`team.member.removed`, `team.leader.added` and `team.leader.removed`. Six rather than four because
leadership is its own authority path: a leadership change has to be expressible without pretending
someone joined or left. Every event carries `externalId`; the four member and leader kinds also carry
`memberKey`. **`team.created` and `team.disbanded` only ask for a reconciliation** — core will not
invent a Team from a delta (it would have no name, no roster and no leaders) and will not archive one
from a delta either, because an archive driven by a message that may simply have been repeated is
destruction on no evidence.
Three narrowings from `MODULE_SYSTEM.md` §2.1, all deliberate:
@@ -217,6 +257,8 @@ api.registerExtension(slot, router)
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.onBoot(async (ctx) => {})
api.onShutdown(async () => {})
```
@@ -316,6 +358,53 @@ meant a `dispatch` that must not be retried and a `classify` that means nothing.
Before it existed, core's post controller required `utils/newsGump` directly — core's publish path
naming a UO file, and the last thing binding core to the module.
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
module becomes the authoritative source of Teams for this deployment.
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
one authoritative source by construction, and two modules answering "what Teams exist" would produce
two disjoint sets under one table with no rule for merging them. A second registration is a
collision, reported against the module that holds it. All three methods are required — a provider
that could list Teams but not their members would leave core holding Teams it can never populate,
which is not the same as a call that fails.
```js
getTeams() // () => Promise<{ ok, complete?, teams }>
getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
{ ok: false, reason: 'sidecar unreachable' }
```
A member is `{ memberKey, displayName?, rankLabel?, leader?, online?, userId? }`. `userId` is
resolved **by the module** — it owns the game↔site link table, and a core that resolved it would be
core reading a module's table by name.
**Every method returns an envelope, never a bare array, and this is the load-bearing part of the
contract.** A rejected promise, a synchronous throw, a timeout (core's budget: **10 seconds**), a
non-object, a missing `ok`, or a structurally malformed row are all read exactly as a deliberate
`{ ok: false }`. There is **no shape a failure can take that core reads as "zero teams"** — which is
the whole argument for the envelope, since a bare array has exactly one such shape, `[]`, and it is
the one a module returns while its sidecar is still connecting.
A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and
surfaces it. It never empties a roster on an answer it does not trust.
`complete: false` means "valid but partial": core applies additions and updates and performs **no**
removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony.
**A malformed row fails the whole call rather than being dropped.** One unreadable member quietly
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.
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
### 2.5 Lifecycle