docs(teams): phase 3 — Teams is a contract, not a surface #156

Merged
whitlocktech merged 2 commits from docs/teams-phase3 into edge 2026-08-18 02:09:34 +00:00
3 changed files with 123 additions and 36 deletions
Showing only changes of commit d78cc99c80 - Show all commits

View File

@@ -841,8 +841,9 @@ from the per-route **siteMode** middleware (§5), never from an auth gate.
| GET | `/wiki` | list of pages (slug + title) | | GET | `/wiki` | list of pages (slug + title) |
| GET | `/wiki/:slug` | single page | | GET | `/wiki/:slug` | single page |
| POST | `/contact` | (rate-limited) send mail via SMTP; if unconfigured, respond `{fallback:"mailto", email}` | | 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, plus `enabled` — whether this deployment has Teams at all, which the client's `teams` nav flag resolves from | | GET | `/teams/by-external/:moduleId/:externalId` | one Team named the way the OWNING MODULE names it. Exists so a module's page can find core's Team without holding core's identifiers, which are core-internal. The module id is matched rather than trusted: an external id is unique only within a module |
| 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. Carries `id`/`externalId`/`moduleId` for the `team.overview` extension slot — this route only, since the index has no slot to feed | | 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, plus `enabled` — whether this deployment has Teams at all |
| 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. Carries `id`/`externalId`/`moduleId` — this route only, since the index has no use for them |
| 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. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result | | 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. **Which rows** appear is the module's audience projection (`projectRoster`), applied per caller: a module that has a rung system and cannot be asked yields an EMPTY roster, not an unprojected one, flagged as `projectionUnavailable`. A session is optional and may widen the result |
| GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members | | GET | `/teams/:slug/activity` | the Team's activity feed, paged, newest first. `public` items to anyone who can see the Team; `members` items additionally to members and forum-granted users, resolved from the session and never from a parameter. `scope` reports which the caller got, so a client can say "some entries are hidden" instead of presenting a filtered feed as the whole one. A hidden Team's feed does not answer the public but does answer its members |
| — | `/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. | | — | `/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. |

View File

@@ -36,14 +36,21 @@ module chunk evaluates, which is earlier than any network round trip could answe
**1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor; **1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor;
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and `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` · `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · the client slots the provider's optional `projectRoster` · `api.registerSlashCommands(...)` ·
`team.overview` and `team.member.row`. `registry.declareModuleSlot(...)` with `Slot` in the UI kit.
> **Amended 2026-08-17 (phase 3), on the org lead's decision: the eighth member joins 1.6.0 in place > **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
> rather than getting a 1.7.0.** The rule is the one Protocol 4 was given in phase 2 — *a contract >
> owes a bump only once it has landed on `main`* — and 1.6.0 has only ever been on `edge`. The same > **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
> amendment marks `ctx.teams.activity.push` and both client slots live, and narrows > 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
> `team.member.row`'s props (TEAMS.md §3.4). > only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
>
> **The client slots `team.overview` and `team.member.row` are replaced by the INVERTED direction.**
> 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.
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven **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 are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
@@ -942,6 +949,7 @@ The kit is **curated and closed**, not a re-export of `components/`:
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has | | `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses | | `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings | | `useAuth`, `useSite` | `contexts/*` | read-only access to session and site settings |
| `Slot` *(1.6.0)* | `modules/Slot.jsx` | renders a place this module declared for core to fill (§3.7a) |
Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself. Everything else — tables, chips, tabs, the tiptap editor, dnd-kit — a module bundles itself.
Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing Adding to the kit is a **minor** `MODULE_API_VERSION` bump; *changing* a kit component's existing
@@ -1178,6 +1186,53 @@ intends to fill permanently, and a slot core *does* intend to fill is a slot tha
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not: members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
declaring is core's, and so is reading back who filled what. declaring is core's, and so is reading back who filled what.
### 3.7a Inverted slots — CORE content inside a MODULE's page *(1.6.0)*
The mirror of §3.7, added for Teams. §3.7 assumes core owns the page and a module contributes to it,
which is right for the footer and the admin user detail. This is the other shape, and the case that
forced it is worth stating because it will recur:
> **A core primitive whose vocabulary core does not own.** Teams are core's — core owns the tables,
> the reconciler, the access resolver and the activity feed — but core has no word for one. A UO shard
> calls them guilds; the next game will call them clans. A core-rendered `/teams` page would publish a
> noun core invented, beside the module's own page for the same thing. So the **page** is the
> module's, and the parts core cannot hand over — here the activity feed, whose public/members split
> only core can resolve — are contributed to it.
```js
// In the module's entry chunk, at registration time:
registry.declareModuleSlot(ID, 'uo.guild.detail')
// In the module's page, from the UI kit:
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
```
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner
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
`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
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
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.
--- ---
## Part 4 — The loader's obligations ## Part 4 — The loader's obligations

View File

@@ -759,6 +759,11 @@ they land in core's committed manifest), and a matching `BACKEND_DESIGN.md` edit
### 3.1 Routes and shell ### 3.1 Routes and shell
> **Superseded 2026-08-17 (phase 3, org lead).** The four public/player rows below are NOT core's.
> Teams is a contract primitive and core does not own the vocabulary, so the module that owns the word
> owns the page: `module-uo` renders these under `/uo/guilds`. Only the two `/admin/teams` rows are
> core's. See the phase 3 amendment in Part 12.
Core client routes, not module ones: Core client routes, not module ones:
| Path | Page | | Path | Page |
@@ -820,18 +825,34 @@ named for a *place* and never for a meaning):
| `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` | | `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` |
| `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` | | `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` |
> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with > **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what
> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so > changed. They assumed core rendered the Team page; core renders no Team page. The replacement is
> those props can only reach it by being published in the roster response to every visitor. The two > `registry.declareModuleSlot(id, name)` — a **module** declares a place on its own page, namespaced
> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and > under its own id, and **core** fills it:
> a module name name no person — and they are served on `GET /teams/:slug` only, not on the index, >
> which has no slot to feed. > | Slot | Declared by | Rendered in | Filled by core with | Props |
> | --- | --- | --- | --- | --- |
Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content — > | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` |
`team.overview` is "the spot under the counts", not "where the game puts guild stats". >
> 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,
> which matters here because the thing being contained is CORE's content failing inside the MODULE's
> page.
>
> The props are the module's own vocabulary. `memberKey`/`userId` are not among them and could not
> be: §3.2 withholds both from every public roster, and a client slot only receives what the browser
> was already sent.
### 3.5 Nav ### 3.5 Nav
> **Superseded 2026-08-17 (phase 3, org lead).** None of the three entries below is registered, and
> the `teams` feature flag is not either. Core publishes no Team nav row because a core row would name
> a surface core does not own, sitting beside the module's own row for the same thing in a different
> word. `/admin/teams`'s sidebar entry, which landed in phase 2 and is an operator view of the
> primitive, is unaffected and stays.
One coded public header entry, `{ label: 'Teams', to: '/teams' }`, plus `{ label: 'My Teams', One coded public header entry, `{ label: 'Teams', to: '/teams' }`, plus `{ label: 'My Teams',
to: '/player/teams' }` in the player portal and `{ label: 'Teams', to: '/admin/teams', group: to: '/player/teams' }` in the player portal and `{ label: 'Teams', to: '/admin/teams', group:
'Community' }` in the admin sidebar. All three flow through the existing registered-defaults → admin 'Community' }` in the admin sidebar. All three flow through the existing registered-defaults → admin
@@ -2069,18 +2090,32 @@ guild called "Admin" cannot put an official-looking page on the site.
### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`) ### Phase 3 — Team pages, roster, nav, activity feed (`website` + `module-uo`)
> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the > **Amended 2026-08-17, while building this.** Six corrections. The first is the org lead's, and it
> thing described below. > changes what this phase ships; the rest were found by building the thing described below.
> >
> **§3.2 and §3.4 contradict each other, and §3.2 wins.** §3.2 says a member key is a game-internal > **THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page.** §3.1 puts `/teams`,
> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares > `/teams/:slug`, `/teams/:slug/roster` and `/player/teams` in core and §3.5 registers three core nav
> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can > entries for them. **Settled (org lead): all seven are dropped.** Core does not own the word for a
> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every > Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a
> public roster response — for every visitor, module installed or not. **Settled: the slot is declared > core page under a noun core invented would sit beside `module-uo`'s existing `/uo/guilds` saying the
> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to > same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the
> put there is a link to the character behind a row and these props do not identify one; filling it > activity feed and the whole API; the **module** builds the pages on that contract. `/admin/teams`
> with a guess from a display name is worse than an empty cell. A future phase that wants this back > stays: an operator inspecting the primitive is looking at the primitive.
> needs an opaque per-response row token, not the raw key. >
> **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.
>
> **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
> up storing them. The module id is matched rather than trusted — an external id is unique only within
> a module.
> >
> **§3.3's projection is an EIGHTH `MODULE_API` member and 1.6.0's list said seven.** Settled by the > **§3.3's projection is an EIGHTH `MODULE_API` member and 1.6.0's list said seven.** Settled by the
> org lead: **1.6.0 is amended in place** rather than bumped, applying the same rule Protocol 4 got in > org lead: **1.6.0 is amended in place** rather than bumped, applying the same rule Protocol 4 got in
@@ -2091,13 +2126,9 @@ guild called "Admin" cannot put an official-looking page on the site.
> rung system could not be consulted* are opposite situations: the first is withholding nothing and > rung system could not be consulted* are opposite situations: the first is withholding nothing and
> must serve the roster whole, the second must serve none of it. The refusal therefore carries > must serve the roster whole, the second must serve none of it. The refusal therefore carries
> `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not > `projects` — `false` for "there is no audience model here", `true` for "there is one and I could not
> ask it" — and only the second fails closed. Without that split, bare core serves an empty roster on > ask it" — and only the second fails closed. Also, the module answers with member **keys**, not rows:
> every Team page. > returning rows would let a module widen what is published by handing back a `userId` core had
> > withheld, leaving core's field guarantee resting on every module's good behaviour.
> **The module answers with member KEYS, not rows.** §3.3's "the module returns the rows it permits"
> would let a module widen what is published by handing back a `userId` core had withheld, and core's
> field guarantee would then rest on every module's good behaviour. Core asks which rows, keeps what a
> row looks like, and re-normalises whatever comes back through its own public shape.
> >
> **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the > **Core's five activity kinds are four here.** `core.forum.thread` has nothing to emit it until the
> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits > forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits