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

@@ -1888,6 +1888,14 @@ this protocol.
## Part 11 — `MODULE_API_VERSION` bump proposal
> **Amended 2026-08-17, on the org lead's decision.** The seven additions below land under **one**
> 1.6.0, declared in phase 2, rather than a minor bump per phase. `MODULE_API.md` therefore documents
> members before they work, so each is marked with the phase that implements it, and the two that do
> not yet — `ctx.teams.activity.push` (§4, phase 3) and `api.registerSlashCommands` (§7.1, phase 7) —
> are **present and throw** with an error naming that phase. Present rather than absent so a module
> written against the published version fails at registration with an explanation, instead of at
> whatever moment someone first exercises the feature.
**1.6.0 — minor.** Every change is an addition; no member is removed and no existing signature changes,
so `MODULE_API.md` §1.1's table gives minor, and `module-uo`'s `coreApi: "^1.3.0"` still resolves.
@@ -1991,6 +1999,40 @@ line with a continuation flag for the pathological guild.
### Phase 2 — Team core (`website` + `module-uo` + `docs`)
> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the
> thing described below.
>
> **§2.5's SQL and §2.10's decision cannot both hold as written.** §2.5 gives `team_forum_grants` a
> generated column `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` and a `CASCADE` foreign
> key; §2.10 later settles that key as `SET NULL` so the audit trail survives an account deletion.
> MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column of a STORED
> generated column (error 1901), so the generated column forces the `CASCADE` — and with it, the loss
> §2.10 exists to prevent. **Settled: §2.10 wins.** The marker is derived from `revoked_at` alone and
> `user_id` moves into the unique KEY, which gives identical semantics — at most one active grant per
> (team, user), unlimited revoked rows — with `user_id` free to be `SET NULL`.
>
> **`team_forum_grants` is created in this phase**, not in phase 4, so the four-path resolver is
> written once and its non-contamination tests are real. Nothing writes it yet; the grant flow, the
> per-Team cap and the leader UI stay phase 4's.
>
> **Two columns on `teams` that this document did not contemplate**, both serving §2.4's gates.
> `roster_synced_at`: `team_sync_state` holds one row per *module*, and gate 3 leaves one Team's roster
> untouched while the others sync — without a per-Team stamp that Team's page would report the
> module's last success as its own, which is exactly the staleness the gate exists to surface.
> `members_empty_since`: gate 4's per-Team quarantine, the twin of `pending_empty_since`.
>
> **`leader` on the member shape is not path 2.** §2.3 puts `leader` on a member and §2.5 says the sync
> writes `is_leader` from `getTeamLeaders()`; taking both literally gives one column two writers, and
> the roster's write lands *first* — so a refused `getTeamLeaders()` silently demoted everyone. The
> roster now **seeds** `is_leader` on insert only, so a Team is not leaderless while that call is
> failing, and `getTeamLeaders()` alone moves it afterwards.
>
> **The §2.8.2 matcher needed two narrow widenings**, both real impersonation vectors the whole-word
> rule missed: a single-word term also matches a name word's singular ("Guild of Moderators"), and a
> run of two or more single-letter words is also compared joined ("G.M."). Neither re-admits substring
> matching — only a trailing `s` off the *whole* term is stripped, and the join is of single letters,
> never of the whole name.
`teams`, `team_members`, `team_sync_state`; `registerTeamProvider` + the three `ctx.teams` members
(**`MODULE_API_VERSION` → 1.6.0**); the reconciler with all four refusal gates; the four-path
resolver with its non-contamination tests; `team_leader_overrides`; the public/player/admin read API;