docs(teams): Team pages and the activity feed, and what phase 3 disproved

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:17:05 -05:00
parent cbc492ea52
commit fd9c02130c
3 changed files with 138 additions and 17 deletions

View File

@@ -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 23)
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).

View File

@@ -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<void>` | `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<void>`**throws until the Team activity feed lands** | — | (1.6.0, declared) |
| `ctx.teams.activity.push` | `(items) => Promise<void>`, 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.

View File

@@ -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.