docs(modules): core offers a contribution, never a slot name

Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule
the eighth and ninth members were given - and it is a correction rather than an
addition.

As first written, the inverted slot direction had core fill three literal
uo.guild.* names. That worked for module-uo and silently did nothing for anyone
else: a module declaring clan.detail under its own id got an empty page and no
error, because "a fill for a slot nobody declared is not an error" is exactly
the rule that makes an unknown name invisible. It also put a module identifier
inside core, in string literals the Sec 5.2 checker masks by construction.

Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three
contributions core offers - team.activity, team.forum, team.notify - as a
table, with the rules that follow from the direction: the member is optional, a
slot that asks for nothing stays empty, more than one slot may ask for the same
contribution, and asking for one core does not offer THROWS at the declaration
rather than rendering empty forever.

TEAMS.md's two accounts of the inversion (Part 3's supersession note and the
phase 3 amendment) say the same thing.

Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in
phase 3 and three places still said eight.

Found by phase 11 while writing the chapter that teaches this shape to an
audience outside this org.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-19 01:16:07 -05:00
parent 6f1f406fe1
commit d10f5809b3
2 changed files with 71 additions and 30 deletions

View File

@@ -37,8 +37,16 @@ module chunk evaluates, which is earlier than any network round trip could answe
`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` and `pageUrlTemplate` · `api.registerSlashCommands(...)` · the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit. `registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an
> optional `{ core }` naming which of core's contributions belongs in the declared place, and core
> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by
> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction
> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the
> inverted direction worked for exactly one module and silently did nothing for any other, which the
> integration kit found while trying to teach it to an audience outside this org.
>
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes. > **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
> >
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member, > **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
@@ -56,8 +64,8 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a > 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 > 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, > 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 > `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a. > CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still **Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
@@ -1050,7 +1058,7 @@ props is a **major** one, because that breaks a call already written. Adding an
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
anything. anything.
The kit is those **eight exports**five rows, because `PageState` contributes three. An earlier The kit is those **nine exports**six rows, because `PageState` contributes three. An earlier
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
@@ -1293,10 +1301,11 @@ forced it is worth stating because it will recur:
> only core can resolve — are contributed to it. > only core can resolve — are contributed to it.
```js ```js
// In the module's entry chunk, at registration time: // In the module's entry chunk, at registration time. The second argument names
registry.declareModuleSlot(ID, 'uo.guild.header') // which of CORE's contributions belongs in that place:
registry.declareModuleSlot(ID, 'uo.guild.detail') registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
registry.declareModuleSlot(ID, 'uo.guild.forum') registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
// In the module's page, from the UI kit: // In the module's page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" /> <Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
@@ -1304,6 +1313,33 @@ registry.declareModuleSlot(ID, 'uo.guild.forum')
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" /> <Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
``` ```
**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and
the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which
worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail`
under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an
error" is exactly the rule that makes an unknown name invisible. It also put a module identifier
inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and
could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`.
So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there:
| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's |
| --- | --- | --- |
| `team.activity` | the Team activity feed | only core can resolve the public/members split on it |
| `team.forum` | the Team forum panel | membership and manual grants are core's rules |
| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team |
`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping
empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that
asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the
module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a
version skew, and the alternative failure is a page that renders empty forever with nothing logged.
**Adding a contribution is a minor bump**; removing one is major.
More than one slot may ask for the same contribution and each gets it. Core has no reason to care how
many places a module wants its feed in, and refusing the second would be core making a layout decision
on a page it does not own.
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same **A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
guild page — core fills them with the Team notification control, the activity feed and the Team forum guild page — core fills them with the Team notification control, the activity feed and the Team forum
— because a slot holds one component and the first fill wins. Collapsing them would hand core the — because a slot holds one component and the first fill wins. Collapsing them would hand core the
@@ -1318,25 +1354,25 @@ readable at the fill site.
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's **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 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 these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first `applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
render. render.
**A fill for a slot no installed module declares is a no-op, never an error.** The declaring module is **A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is
simply not installed, which is the ordinary case on any deployment — the exact mirror of an unfilled 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
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. 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 **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. 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 **`Slot` is the ninth 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 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 matters more here than anywhere else in the kit, because the thing being contained is *core's* content
failing inside the *module's* page. failing inside the *module's* page.
`declareModuleSlot` is on the `registry` object handed to modules. `fillModuleSlot` and `declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills`
`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is. and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7
slot is.
--- ---

View File

@@ -830,18 +830,20 @@ named for a *place* and never for a meaning):
> **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what > **Superseded 2026-08-17 (phase 3, org lead).** Both slots are gone, and the DIRECTION is what
> changed. They assumed core rendered the Team page; core renders no Team page. The replacement is > changed. They assumed core rendered the Team page; core renders no Team page. The replacement is
> `registry.declareModuleSlot(id, name)` — a **module** declares a place on its own page, namespaced > `registry.declareModuleSlot(id, name, { core })` — a **module** declares a place on its own page,
> under its own id, and **core** fills it: > namespaced under its own id, naming which of core's contributions goes there, and **core** offers it:
> >
> | Slot | Declared by | Rendered in | Filled by core with | Props | > | Slot | Declared by | Rendered in | Filled by core with | Props |
> | --- | --- | --- | --- | --- | > | --- | --- | --- | --- | --- |
> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` | > | `uo.guild.detail` | `module-uo` | its guild detail page | `team.activity` — the Team activity feed (§4.3) | `{ externalId, moduleId }` |
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | the Team forum (Part 5) added in phase 4 | `{ externalId, moduleId }` | > | `uo.guild.forum` | `module-uo` | the same page, below the feed | `team.forum` — the Team forum (Part 5), added in phase 4 | `{ externalId, moduleId }` |
> >
> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk, > Core's contributions are applied at MOUNT, not eagerly: core's bundle evaluates before every module
> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module > chunk, so when core offers one, no module-declared slot exists yet. A contribution nothing asks for
> declares is a no-op, not an error — the mirror of an unfilled slot rendering nothing. `Slot` becomes > is a no-op, not an error — the mirror of an unfilled slot rendering nothing. **Core names the
> the eighth member of the shared UI kit so a module renders the place with core's own error boundary, > contribution and never the slot** (amended phase 11, inside 1.6.0: as first built it filled the
> literal names above, which reached `module-uo` and no other game). `Slot` becomes
> the ninth 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 > which matters here because the thing being contained is CORE's content failing inside the MODULE's
> page. > page.
> >
@@ -2535,13 +2537,16 @@ guild called "Admin" cannot put an official-looking page on the site.
> >
> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview` > **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 > 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 > `registry.declareModuleSlot(id, name, { core })`: a **module** declares a place on its own page,
> under its own id, and **core** fills it. `module-uo` declares `uo.guild.detail`; core fills it with > namespaced under its own id, and names which of core's contributions belongs there. `module-uo`
> the activity feed, because only core can resolve whether a viewer is inside the Team and the > declares `uo.guild.detail` and asks for `team.activity`; core offers the activity feed, because only
> public/members split is a security boundary. Core's fills are applied at mount rather than eagerly — > core can resolve whether a viewer is inside the Team and the public/members split is a security
> core's bundle evaluates before every module chunk, so at the moment core registers a fill the slot > boundary. **Core names the contribution, never the slot** — amended in phase 11, inside 1.6.0, after
> does not exist yet. `Slot` joins the shared UI kit as its eighth member so the module renders the > the integration kit found that the literal-name version worked for one module and silently did
> place with core's own error boundary. > nothing for any other. Core's contributions are applied at mount rather than eagerly — core's bundle
> evaluates before every module chunk, so at the moment core offers one, no module-declared slot exists
> yet. `Slot` joins the shared UI kit as its ninth 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` > **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 > is added: core's row id and slug are core-internal and handing them to a module is how a module ends