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

@@ -525,6 +525,55 @@ have been found earlier, because until then no caller had ever passed a non-null
Design of record: [`MODULE_SYSTEM.md`](MODULE_SYSTEM.md) §2.4; the loader's obligations are
[`MODULE_API.md`](MODULE_API.md) Part 4.
### The six Team tables — core's, populated by a module (Teams phase 2)
A Team is a **core** entity that a **module** answers for. The module says what Teams exist and who is
in them, through the team provider; core stores that answer, gates it and displays it. Every table
here is core-internal — a module must never read or write one, even though a module is what fills
them — and none carries a `<moduleId>_` prefix, correctly: that rule binds modules, and these are
core's.
| Table | What it holds |
|---|---|
| `teams` | the Team itself. `external_id` is the module's own stable id, opaque to core; `name` is **immutable** for the life of the row; `slug` is derived once at create and frozen with it |
| `team_members` | the membership **projection**. Module-authoritative, and the sync is its only writer. Rows are soft-departed rather than deleted so history and rejoins survive |
| `team_sync_state` | one row per module: last attempt, last success, consecutive failures, last error, and the empty-answer quarantine |
| `team_leader_overrides` | a staff decision about leadership, applied **on top of** the synced value at read time and never written into the projection |
| `team_forum_grants` | the append-only forum grant/revoke ledger, which is also the current state. Created in this phase so the access resolver is written once; the grant flow lands with the forums |
| `team_moderation_requests` | the approval queue for the three actions that publish untrusted game-sourced strings |
**A rename is an archive plus a create**, never an edit. Core's identity is (`module_id`,
`external_id`, `name`) taken together: a known id under a new name archives the old row
(`archived_reason='renamed'`, `succeeded_by` pointing at the successor) and creates a new one, so the
old Team keeps its activity, its grants and its forum as a read-only record and its old slug still
resolves. Whether two names are "really" the same guild is the module's judgement, expressed in
whether it reuses the external id.
**Uniqueness among ACTIVE rows only** is expressed with STORED generated columns, because MariaDB has
no partial index and NULL never collides in a UNIQUE key: `active_key` and `active_slug` on `teams`
are NULL for archived rows, so any number of them may share an `external_id`.
**`team_forum_grants` departs from the obvious encoding, and the reason matters.** Its marker is
`active_marker AS (IF(revoked_at IS NULL, 1, NULL))` with `user_id` in the KEY rather than the
generated column, because MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base
column of a stored generated column (error 1901) — and `SET NULL` is required here: `CASCADE` would
delete the audit trail of who granted whom, which is exactly what an audit exists to survive. The
semantics are identical: at most one active grant per (team, user), unlimited revoked rows.
**Account deletion is settled per column, not inherited from the defaults.** Content and audit
survive; preferences and links do not. `team_members.user_id` and every actor column on the grant
ledger and the approval queue go `SET NULL` with a **username snapshot** alongside, so the record
stays readable after the account is gone. Only `team_id` cascades.
**Two columns exist that the design of record did not contemplate**, both on `teams` and both
serving the refusal gates below: `roster_synced_at`, because `team_sync_state` holds one row per
*module* and a single Team's roster can be left untouched while the others sync — without a per-Team
stamp that Team's page would report the module's last success as its own; and `members_empty_since`,
the per-Team twin of `pending_empty_since`.
Design of record: [`TEAMS.md`](TEAMS.md) Part 2. The contract surface a module sees is
[`MODULE_API.md`](MODULE_API.md); everything in these tables is explicitly *not* it.
---
## 4. API contract
@@ -654,6 +703,14 @@ and relies on it: its `/player/shard/*` handlers are the identical self-scoped o
under `/admin/shard/*`, so the two are interchangeable. This is why a staff account with linked game characters gets its "My characters" and
personal notification streams on the mobile client — the group no longer 403s a non-`player` role.
`teams.router.js` joins the group in Teams phase 2, and relies on exactly that rule: a moderator is in
guilds too, and gating this group on the role would 403 them off their own Teams.
| Method | Path | Notes |
|---|---|---|
| GET | `/teams` | the caller's Teams, each carrying the **reason** it is listed: `membership` \| `grant` \| `both`. Membership and forum access are separate authority paths and the reason is what keeps them distinguishable — `both` is a real state, and a Team **hidden** from public surfaces is still listed here, because suppression is a public-surface rule and a member is not a member of the public |
| GET | `/teams/:slug/access` | the caller's own resolved access on one Team: `allowed`, `viaMembership`, `viaGrant` (kept even when membership also holds, so the grant survives as audit history) and `isLeader` with any staff override applied |
**Password reset.** Uses the same audited pattern as `user_invites`: an opaque 32-byte token
whose **sha256 hash only** is stored in `password_resets`, single-use and short-lived (~1h). It
also serves SSO-only accounts (null `password_hash`) as their "set an initial password" path. The
@@ -772,6 +829,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` |
| GET | `/teams` | active, publicly visible Teams, paged. Every payload carries `{ configured, stale, lastSyncAt }` so a page can say how recently the projection was confirmed rather than presenting a stale roster as current |
| GET | `/teams/:slug` | one Team. An **archived** Team still resolves, read-only, and names its successor when it was renamed — an old bookmark or Discord link lands somewhere that explains itself. A **hidden** Team returns 404, indistinguishable from one that does not exist: "absent from every public surface" includes not confirming it is there |
| GET | `/teams/:slug/members` | the roster. In-game display names only — the member key is a game-internal identifier and the user id names a site account, and **neither is published**; `linked` answers whether a character has an account behind it without saying which. The module's per-audience field projection lands with the Team pages |
| — | `/shard/*` · `/atlas/*` | **Served by `module-uo`, not by core** (25 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md); absent entirely when the module is not installed, which is a 404 and not an error. |
Public content GETs pass through the **siteMode** gate (§5).
@@ -842,6 +902,15 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/modules/:id/disable` | the one module action that takes effect immediately — dispatches that module's `onShutdown`, then its routes, nav and client chunk answer 404. A real kill switch, not a visibility flag |
| POST | `/modules/:id/purge` | run a **disabled** module's `purge.sql`, dropping its tables and data. `409` while it is still running; `400` if it ships no `purge.sql` |
| DELETE | `/modules/:id[?purge=true]` | uninstall: stop, then (with `purge=true`) drop its data, then delete its directory. Non-destructive by default — the row stays `disabled` and the data is left for a reinstall to pick up. The purge option lives here because it cannot live after: `purge.sql` is a file inside the directory being deleted |
| GET | `/teams` | every Team incl. hidden ones, plus the module's **sync state verbatim** — last attempt, last success, consecutive failures, the last error and any held empty answer. Verbatim because an operator debugging a stale projection needs what the provider actually said |
| GET | `/teams/:id` | one Team with its roster (departed members included), its grant ledger and its pending requests. Each roster row carries the **resolved** leadership and `isLeaderSynced` — what the game actually said — so an override reads as a decision rather than as fact |
| POST | `/teams/resync` | run a reconciliation now, **awaited**, so the response carries the outcome including the provider's own refusal reason. The four refusal gates still apply: a manual resync cannot make core act on an answer it does not trust |
| POST | `/teams/:id/archive` · `/teams/:id/hide` | staff archive / hide. **Not gated** — both withdraw a Team from public surfaces rather than publishing anything, and withdrawing has to be possible at once, by whoever is on duty |
| POST | `/teams/:id/unhide` · `/teams/:id/display-name` | the two **gated** actions (§2.9): an admin applies at once, a **moderator** files a pending request and nothing changes publicly. The caller does not choose — the server decides from the role it re-validates on the request |
| GET | `/teams/:id/grants` | the full forum-grant ledger, revoked rows included. Read-only in this phase; the grant flow lands with the forums |
| POST | `/teams/:id/leader-override` · DELETE `…/:memberKey` | set or clear a staff leadership decision, applied **on top of** the synced value at read time. Not gated: it publishes no game-sourced string |
| GET | `/teams/review` | the reserved-name review queue — Teams auto-hidden because their name matched, each showing which term |
| GET | `/teams/requests` · POST `…/:id/decide` | the approval queue, and the decision. **Admin only** to decide, checked live rather than from a token claim; a request already decided returns `409`, so two admins deciding at once cannot double-apply |
| — | `/shard/*` · `/uo-link/*` | **Served by `module-uo`, not by core** (33 routes). Documented in [`../modules/uo/API.md`](../modules/uo/API.md) |
Every admin write logs to `activity_log`.