docs(teams): Teams is a contract, not a surface — record the correction

The org lead's correction to Part 3, and the inverted extension-slot direction
it forces.

TEAMS.md: §3.1's routes, §3.4's two slots and §3.5's three nav entries are all
marked superseded in place, and Part 12's phase 3 entry gains the amendment
explaining why — core does not own the word for a Team, so the module that owns
the vocabulary owns the page. The five corrections found by building are kept
alongside it.

MODULE_API.md: 1.6.0's list swaps the two client slots for
registry.declareModuleSlot + Slot in the UI kit, and a new §3.7a documents the
inverted direction: what forced it, the enforced namespace, why core's fills are
applied at mount rather than eagerly, and why a fill for an undeclared slot is a
no-op where §3.7's unknown slot throws.

BACKEND_DESIGN.md: the by-external-id lookup route.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:58:40 -05:00
parent fd9c02130c
commit d78cc99c80
3 changed files with 123 additions and 36 deletions

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;
`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` ·
the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · the client slots
`team.overview` and `team.member.row`.
the provider's optional `projectRoster` · `api.registerSlashCommands(...)` ·
`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
> 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
> amendment marks `ctx.teams.activity.push` and both client slots live, and narrows
> `team.member.row`'s props (TEAMS.md §3.4).
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
>
> **The eighth member joins 1.6.0 in place 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`. `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
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 |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
| `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.
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:
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