From fd9c02130cc5009274905bf28614a87f1d15d5ec Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:17:05 -0500 Subject: [PATCH 1/2] docs(teams): Team pages and the activity feed, and what phase 3 disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAMS.md gains a dated amendment on phase 3 with five corrections, all found by building the thing it describes: - §3.2 and §3.4 contradict each other about `team.member.row`'s props, and §3.2 wins because it is the security rule. A client slot can only receive what the browser was sent, so §3.4's `{ memberKey, userId, displayName }` means publishing both identifiers in every public roster, module installed or not. The slot is redeclared with what core can honestly supply. - §3.3's projection is an EIGHTH MODULE_API member where 1.6.0 listed seven. Settled by the org lead: 1.6.0 is amended in place, on the rule Protocol 4 was given in phase 2 — a contract owes a bump only once it has reached `main`. - "the module declines" needed splitting in two before it could be built. No module at all withholds nothing and must serve the roster whole; a module whose rungs could not be consulted must serve none of it. Only the second fails closed, or bare core shows an empty roster on every Team page. - the module answers with member KEYS, not rows, so it can narrow what is published and cannot widen it. - core's five activity kinds are four until the forum lands, and a Team's FIRST roster emits no join items at all. §2.11's route table gains the activity endpoint it never had, and MODULE_API.md documents `projectRoster`, the inverted fail-closed semantics that make it different from every other provider call, and `ctx.teams.activity.push`'s item shape and its four contractual properties. BACKEND_DESIGN.md: the seventh Team table, its retention, and the three public routes' new behaviour — `enabled` on the index, the slot props on the single Team, the per-caller row projection on the roster, and the feed. Co-Authored-By: Claude --- website/BACKEND_DESIGN.md | 21 ++++++++--- website/MODULE_API.md | 76 +++++++++++++++++++++++++++++++++------ website/TEAMS.md | 58 ++++++++++++++++++++++++++++-- 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index ec76ec5..6db4234 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -525,7 +525,7 @@ 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) +### The seven Team tables — core's, populated by a module (Teams phases 2–3) 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 @@ -541,6 +541,18 @@ core's. | `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 | +| `team_activity` | the per-Team feed (phase 3). **Two writers, one table:** core writes its own membership and rename items with `source='core'`, and a module pushes game items through `ctx.teams.activity.push`. `summary` is already-rendered text and core never composes one; `kind` and `payload` are opaque to core | + +**`team_activity` is bounded on purpose.** A feed fed by a game loop is the obvious unbounded-growth +failure, so retention ships with the feed rather than after someone notices: a nightly worker applies +an age horizon (`team_activity_retain_days`, default 90) **and** a per-Team row cap +(`team_activity_row_cap`, default 2000). Both, because either alone has a hole — age lets one busy +guild write a million rows inside the window, and a cap keeps a dead Team's feed forever. + +`dedupe_key` is optional and unique per Team, written with `INSERT IGNORE` — the same idempotence +trick `shard_events` uses, and what makes a sidecar reconnect backfill safe to replay. Core +deliberately emits **no join items for a Team's first roster** (`roster_synced_at IS NULL`): importing +a 155-member guild is one Team arriving, not 155 people joining. **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 @@ -829,9 +841,10 @@ 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 | +| 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/: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/: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 | | — | `/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). diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 0da19c5..7342d3b 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -33,17 +33,23 @@ The client half carries the same number (`client/src/modules/version.js`) and a agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first module chunk evaluates, which is earlier than any network round trip could answer. -**1.6.0 — Teams, the whole surface.** Seven 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 `ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` · -`api.registerSlashCommands(...)` · the client slots `team.overview` and `team.member.row`. +the provider's optional `projectRoster` · `api.registerSlashCommands(...)` · the client slots +`team.overview` and `team.member.row`. -**The number covers the whole surface; the members arrive by phase, and each is marked below.** Three -are live now. `ctx.teams.activity.push` and `api.registerSlashCommands` are **present and throw**, with -an error naming the phase that will implement them — chosen over leaving them absent so that a module -written against the published version fails at registration with a sentence explaining itself, rather -than at whatever moment someone first exercises the feature. Do not call them yet; do not treat a -throw as a bug. +> **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). + +**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 +that will implement it — chosen over leaving it absent so that a module written against the published +version fails at registration with a sentence explaining itself, rather than at whatever moment +someone first exercises the feature. Do not call it yet; do not treat its throw as a bug. **`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every existing one is either the module claiming a mount or core notifying it; the closest precedent is @@ -201,13 +207,36 @@ module-uo does not need is on the list. | `ctx.moduleId` | the id from `module.json` | loader | log tags, table checks | | `ctx.teams.publish` | `(event) => Promise` | `model/teams/teamSync` | the Team provider's module (1.6.0) | | `ctx.teams.reconcile` | `({ reason }) => void`, returns at once | `model/teams/teamSync` | after a fresh account link (1.6.0) | -| `ctx.teams.activity.push` | `(items) => Promise` — **throws until the Team activity feed lands** | — | (1.6.0, declared) | +| `ctx.teams.activity.push` | `(items) => Promise`, fire-and-forget | `model/teams/teamActivity` | the Team provider's module (1.6.0) | **`ctx.teams` is push only, and that is the contract.** There is no reader: a module *answers* questions about Teams, it does not ask them. Every Team table is core-internal (§1.2), and a `getTeamRoster` on `ctx` would be core offering to read back the module's own answer — which the module already holds, in its own store. +**`ctx.teams.activity.push(items)`** writes the per-Team feed (TEAMS.md §4.1). Each item is: + +```js +{ externalId, kind, summary, occurredAt?, visibility?, actorMemberKey?, actorUserId?, payload?, dedupeKey? } +``` + +Four things about it are contract rather than implementation: + +- **`summary` is already rendered and core stores it verbatim.** Core cannot phrase "gained 15,000 + gold" for a game whose vocabulary it does not know, and a core that templated it would have + re-acquired exactly the semantics the module system exists to remove. `kind` and `payload` are + likewise opaque — core filters on them and only the module's `team.overview` slot renders anything + richer than the text. +- **A Team is named by the module's own `externalId`**, which core maps, and only that module's + ACTIVE Teams resolve. There is no id a module can send that reaches another module's Team, and an + archived Team is not writable — its feed is a closed record of what happened before the rename. +- **`visibility` defaults to `'members'` — fail closed.** The module chooses it per item; core + enforces it on the read path. +- **It never throws at the call site and never rejects.** This is called from inside a game-event + handler, and a storage problem of core's must not become the module's control flow. A malformed + item is dropped and logged; a `dedupeKey` collision is a successful no-op, which is what makes a + sidecar reconnect backfill safe to replay. + Both live members are **fire-and-forget**. `publish` is an optimisation that makes a membership change visible at once; `reconcile` is a debounced *request* that returns immediately and never rejects. Correctness comes from reconciliation either way, so neither can make a module's own call @@ -364,14 +393,16 @@ module becomes the authoritative source of Teams for this deployment. **One provider per deployment.** Unlike every other registry, this holds a single value: Teams have one authoritative source by construction, and two modules answering "what Teams exist" would produce two disjoint sets under one table with no rule for merging them. A second registration is a -collision, reported against the module that holds it. All three methods are required — a provider +collision, reported against the module that holds it. Three methods are required — a provider that could list Teams but not their members would leave core holding Teams it can never populate, -which is not the same as a call that fails. +which is not the same as a call that fails. The fourth is optional; see below. ```js getTeams() // () => Promise<{ ok, complete?, teams }> getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }> getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey] +projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3) + // => Promise<{ ok, members }> // members = [memberKey] // authoritative { ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] } @@ -393,6 +424,29 @@ the one a module returns while its sidecar is still connecting. A refusal costs staleness and nothing else: core keeps the projection it has, records the reason, and surfaces it. It never empties a roster on an answer it does not trust. +**`projectRoster` is the exception to that last paragraph, and the exception is deliberate.** It +answers *who is allowed to look at a roster*, on the request path, because the audience model and its +configuration are the module's and core does not have one (TEAMS.md §3.3). For a visibility question, +"keep what you have" is a leak: leaving the answer alone means serving the roster unprojected to +whoever asked. So this one call **fails closed**. + +Core distinguishes two refusals, and a module does not have to do anything to get the right one: + +- **no provider, or a provider without `projectRoster`** — there is no audience model to consult and + nothing is being withheld, so core serves the roster whole at its own public shape. This is what + makes the member genuinely optional: bare core, and a module with no rungs of its own, both render + the page core writes. +- **a provider that HAS `projectRoster` and refused, threw, timed out or answered malformed** — core + serves an empty roster and says so in the response (`projected: false`, + `projectionUnavailable: true`). + +Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for +an anonymous caller — and never the `users` row, which would make every column of that table part of +this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a +published row looks like and re-normalises whatever comes back through its own public shape, so a +module can narrow which rows appear and cannot widen which fields do: the member key and the site +account id are withheld from every public roster whatever a module returns. + `complete: false` means "valid but partial": core applies additions and updates and performs **no** removals. It defaults to `true` when omitted, so the ordinary authoritative case needs no ceremony. diff --git a/website/TEAMS.md b/website/TEAMS.md index 18961bc..5411619 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -717,7 +717,10 @@ place it bites is uploads, where "forget me" has to mean the bytes go too, not j ``` GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt} GET /api/v1/public/teams/:slug overview + counts -GET /api/v1/public/teams/:slug/members roster, field-projected per audience rung (§3.3) +GET /api/v1/public/teams/:slug/members roster, row-projected per audience rung (§3.3) +GET /api/v1/public/teams/:slug/activity the feed, paged, filtered to what the caller may + see (§4.3) — added in phase 3; a session is + optional on this route and on /members GET /api/v1/player/teams the caller's Teams (membership + grants), with the reason for each: 'membership' | 'grant' | both @@ -815,7 +818,14 @@ named for a *place* and never for a meaning): | Slot | Rendered in | Props | | --- | --- | --- | | `team.overview` | the Team overview page, below the counts | `{ teamId, externalId, moduleId }` | -| `team.member.row` | each roster row, trailing cell | `{ memberKey, userId, displayName }` | +| `team.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` | + +> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with +> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so +> those props can only reach it by being published in the roster response to every visitor. The two +> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and +> 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. Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content — `team.overview` is "the spot under the counts", not "where the game puts guild stats". @@ -2059,6 +2069,50 @@ guild called "Admin" cannot put an official-looking page on the site. ### 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 +> 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 +> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares +> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can +> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every +> public roster response — for every visitor, module installed or not. **Settled: the slot is declared +> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to +> put there is a link to the character behind a row and these props do not identify one; filling it +> with a guess from a display name is worse than an empty cell. A future phase that wants this back +> needs an opaque per-response row token, not the raw key. +> +> **§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 +> phase 2 — a contract owes a bump only once it has landed on `main`, and 1.6.0 is on `edge` only. +> +> **"The module declines" needed splitting in two before it could be implemented.** §3.3 says a module +> that declines yields the public projection, fail closed. But *no module at all* and *a module whose +> 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 +> `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 +> every Team page. +> +> **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 +> forum lands in phase 4. Separately, and not in the doc at all: **the first roster for a Team emits +> no join items.** Importing a 155-member guild is one Team arriving, not 155 people joining, and +> emitting a join per member would bury every real event under the import and reach the row cap on day +> one. `roster_synced_at IS NULL` is the condition, which covers both a new Team and a newly installed +> module adopting an existing one. +> +> **§2.11's route table has no activity endpoint** though §4.3 describes a feed filtered by the +> viewer's access. Added on the org lead's decision: `GET /api/v1/public/teams/:slug/activity`, paged, +> with the visibility resolved from the session and never from a parameter. It is the first public +> route whose *content* depends on identity, which needed a new `optionalAuth` middleware — +> `attachSession` only decodes a token, so a banned or logged-out account would have kept reading the +> members-only half until its JWT expired. + `/teams`, `/teams/:slug`, `/teams/:slug/roster`, `/player/teams`; the linked/unlinked/guest surface; `team.overview` + `team.member.row` slots; nav registration; `team_activity` + `ctx.teams.activity.push` + core's own five activity kinds + the retention prune. -- 2.49.1 From d78cc99c80698c58f01c2227ba93f1e0f0a4e597 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 20:58:40 -0500 Subject: [PATCH 2/2] =?UTF-8?q?docs(teams):=20Teams=20is=20a=20contract,?= =?UTF-8?q?=20not=20a=20surface=20=E2=80=94=20record=20the=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- website/BACKEND_DESIGN.md | 5 ++- website/MODULE_API.md | 69 +++++++++++++++++++++++++++---- website/TEAMS.md | 85 ++++++++++++++++++++++++++------------- 3 files changed, 123 insertions(+), 36 deletions(-) diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index 6db4234..0645246 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -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/: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, plus `enabled` — whether this deployment has Teams at all, which the client's `teams` nav flag resolves from | -| 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/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` | 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/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. | diff --git a/website/MODULE_API.md b/website/MODULE_API.md index 7342d3b..b44ea4e 100644 --- a/website/MODULE_API.md +++ b/website/MODULE_API.md @@ -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: + +``` + +**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 diff --git a/website/TEAMS.md b/website/TEAMS.md index 5411619..81dc47e 100644 --- a/website/TEAMS.md +++ b/website/TEAMS.md @@ -759,6 +759,11 @@ they land in core's committed manifest), and a matching `BACKEND_DESIGN.md` edit ### 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: | 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.member.row` | each roster row, trailing cell | `{ displayName, isLeader, linked }` | -> **Amended 2026-08-17 (phase 3).** `team.member.row` was specified with -> `{ memberKey, userId, displayName }`, which §3.2 forbids: a slot component runs in the browser, so -> those props can only reach it by being published in the roster response to every visitor. The two -> identifiers are dropped. `team.overview`'s three are kept — a core row id, a game-side group id and -> 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. - -Both unfilled on bare core, which renders exactly the page core writes. Neither is typed by content — -`team.overview` is "the spot under the counts", not "where the game puts guild stats". +> **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 +> `registry.declareModuleSlot(id, name)` — a **module** declares a place on its own page, namespaced +> under its own id, and **core** fills it: +> +> | 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 }` | +> +> 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 +> **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', 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 @@ -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`) -> **Amended 2026-08-17, while building this.** Five corrections, all found by building or testing the -> thing described below. +> **Amended 2026-08-17, while building this.** Six corrections. The first is the org lead's, and it +> 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 -> identifier and a user id names a site account, and that **neither is published**; §3.4 then declares -> the `team.member.row` slot with props `{ memberKey, userId, displayName }`. A client-side slot can -> only receive what the browser was sent, so honouring §3.4 means putting both identifiers into every -> public roster response — for every visitor, module installed or not. **Settled: the slot is declared -> with `{ displayName, isLeader, linked }`.** module-uo leaves it unfilled, because the useful thing to -> put there is a link to the character behind a row and these props do not identify one; filling it -> with a guess from a display name is worse than an empty cell. A future phase that wants this back -> needs an opaque per-response row token, not the raw key. +> **THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page.** §3.1 puts `/teams`, +> `/teams/:slug`, `/teams/:slug/roster` and `/player/teams` in core and §3.5 registers three core nav +> entries for them. **Settled (org lead): all seven are dropped.** Core does not own the word for a +> Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a +> core page under a noun core invented would sit beside `module-uo`'s existing `/uo/guilds` saying the +> same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the +> activity feed and the whole API; the **module** builds the pages on that contract. `/admin/teams` +> stays: an operator inspecting the primitive is looking at the primitive. +> +> **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 > 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 > 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 -> ask it" — and only the second fails closed. Without that split, bare core serves an empty roster on -> every Team page. -> -> **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. +> ask it" — and only the second fails closed. Also, the module answers with member **keys**, not rows: +> 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. > > **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 -- 2.49.1