feat(teams): the activity feed, the roster projection, and the inverted slot #152

Merged
whitlocktech merged 5 commits from feat/teams-phase3-pages-activity into edge 2026-08-18 02:11:08 +00:00
Member

Teams phase 3, core's half. Pairs with Module-uo#11 and docs#156.

What this ships

The Teams contract, and nothing user-facing. Core owns team_activity, the reconciler's own
items, the retention prune, the roster's audience projection and the whole API. It renders no Team
page and registers no Team nav row — see the correction below.

  • team_activity and ctx.teams.activity.push, which stops throwing and starts working. Two
    writers, one table: core writes membership and rename items with source='core', a module pushes
    game items. summary is already-rendered text and core never composes one.
  • Four of §4.2's five core kinds. core.forum.thread has nothing to emit it until phase 4.
  • Retention: an age horizon and a per-Team row cap, both settings. Either alone has a hole.
  • GET /public/teams/:slug/activity — paged, visibility resolved from the session and never from
    a parameter.
  • projectRoster, the eighth member of MODULE_API 1.6.0 (amended in place per the org lead).
  • optionalAuth, a new middleware.
  • GET /public/teams/by-external/:moduleId/:externalId, so a module can name a Team its own way.

The correction, mid-PR

The first four commits built four core Team pages and three nav rows, following §3.1 and §3.5. The
org lead corrected it: Teams is the platform primitive the API contract exposes; the module builds
the pages on it.
module-uo builds guilds, the Rust module will build clans. Core does not own the
word for a Team.

The fifth commit removes all of that and inverts the extension-slot direction:
registry.declareModuleSlot(id, name) lets a module declare a place on its own page for core
to fill. Core fills uo.guild.detail with the activity feed — the one part of that page core cannot
hand over, since only core can resolve whether the viewer is inside the Team.

It is left as an add-then-remove rather than rewritten history because the reasoning is worth
reading; the net diff is what ships.

Five things the build disproved (all recorded as TEAMS.md amendments)

  1. §3.2 and §3.4 contradict each other. §3.4 gives the row slot memberKey/userId; §3.2 says
    neither is ever published. A client slot only receives what the browser was sent, so §3.4 meant
    publishing both to every visitor. §3.2 wins.
  2. "The module declines" needed splitting. No module at all and a module whose rungs could not be
    consulted are opposite situations. Only the second fails closed — otherwise bare core serves an
    empty roster on every page.
  3. The module answers with member keys, not rows, or it could widen what core publishes.
  4. A Team's first roster emits no join items. Importing a 155-member guild is one Team arriving.
  5. §2.11 had no activity endpoint though §4.3 describes a filtered feed.

Testing

  • npm test (server): 931 tests. session.test.js and botScore.test.js are pre-existing timing
    flakes — both pass in isolation.
  • npm test (client): 224 tests, including the inverted slot's ordering and no-op rules.
  • Swagger + both route manifests regenerated; routes.guards.json names optionalAuth on the two
    public routes that now read identity.

Live-rig acceptance

Full rig per the org lead: real ServUO (two synthesized guilds from the real world), real Rust
sidecar, website with module-uo installed. Proved:

  • reserved-name screening auto-hid "Guild of Moderators"; it 404s on every public lookup;
  • the rung projection live — guilds at staff gave an anonymous caller 0 rows and an admin
    25, same URL;
  • the feed from a real roster change: 2 leaves + 2 joins, named by the display name core last
    knew them by;
  • ctx.teams.activity.push through the real ctx, and the members-only item withheld from
    anonymous and from an admin who is not a member, and served to a genuine linked member;
  • the inverted slot rendering core's feed inside module-uo's guild page.

One finding worth flagging: module-uo's core.js facade has no teams member and the module has
never called ctx.teams.* at all — publish/reconcile shipped in phase 2 unused. The probe needed
a temporary passthrough. Not a defect in this PR, but ctx.teams is unexercised from a real module.


  • AI-assisted: written with Claude Code (Claude Opus 5).
Teams phase 3, core's half. Pairs with Module-uo#11 and docs#156. ## What this ships **The Teams contract, and nothing user-facing.** Core owns `team_activity`, the reconciler's own items, the retention prune, the roster's audience projection and the whole API. It renders no Team page and registers no Team nav row — see the correction below. - **`team_activity`** and `ctx.teams.activity.push`, which stops throwing and starts working. Two writers, one table: core writes membership and rename items with `source='core'`, a module pushes game items. `summary` is already-rendered text and core never composes one. - **Four of §4.2's five core kinds.** `core.forum.thread` has nothing to emit it until phase 4. - **Retention**: an age horizon *and* a per-Team row cap, both settings. Either alone has a hole. - **`GET /public/teams/:slug/activity`** — paged, visibility resolved from the session and never from a parameter. - **`projectRoster`**, the eighth member of MODULE_API 1.6.0 (amended in place per the org lead). - **`optionalAuth`**, a new middleware. - **`GET /public/teams/by-external/:moduleId/:externalId`**, so a module can name a Team its own way. ## The correction, mid-PR The first four commits built four core Team pages and three nav rows, following §3.1 and §3.5. The org lead corrected it: **Teams is the platform primitive the API contract exposes; the module builds the pages on it.** module-uo builds guilds, the Rust module will build clans. Core does not own the word for a Team. The fifth commit removes all of that and inverts the extension-slot direction: `registry.declareModuleSlot(id, name)` lets a **module** declare a place on its own page for **core** to fill. Core fills `uo.guild.detail` with the activity feed — the one part of that page core cannot hand over, since only core can resolve whether the viewer is inside the Team. It is left as an add-then-remove rather than rewritten history because the reasoning is worth reading; the net diff is what ships. ## Five things the build disproved (all recorded as TEAMS.md amendments) 1. **§3.2 and §3.4 contradict each other.** §3.4 gives the row slot `memberKey`/`userId`; §3.2 says neither is ever published. A client slot only receives what the browser was sent, so §3.4 meant publishing both to every visitor. §3.2 wins. 2. **"The module declines" needed splitting.** No module at all and a module whose rungs could not be consulted are opposite situations. Only the second fails closed — otherwise bare core serves an empty roster on every page. 3. **The module answers with member *keys*, not rows**, or it could widen what core publishes. 4. **A Team's first roster emits no join items.** Importing a 155-member guild is one Team arriving. 5. **§2.11 had no activity endpoint** though §4.3 describes a filtered feed. ## Testing - `npm test` (server): 931 tests. `session.test.js` and `botScore.test.js` are pre-existing timing flakes — both pass in isolation. - `npm test` (client): 224 tests, including the inverted slot's ordering and no-op rules. - Swagger + both route manifests regenerated; `routes.guards.json` names `optionalAuth` on the two public routes that now read identity. ## Live-rig acceptance Full rig per the org lead: real ServUO (two synthesized guilds from the real world), real Rust sidecar, website with module-uo installed. Proved: - reserved-name screening auto-hid "Guild of Moderators"; it 404s on every public lookup; - the rung projection live — `guilds` at `staff` gave an anonymous caller **0 rows** and an admin **25**, same URL; - the feed from a **real roster change**: 2 leaves + 2 joins, named by the display name core last knew them by; - `ctx.teams.activity.push` through the **real ctx**, and the members-only item withheld from anonymous *and* from an admin who is not a member, and served to a genuine linked member; - the inverted slot rendering core's feed inside module-uo's guild page. **One finding worth flagging:** module-uo's `core.js` facade has no `teams` member and the module has never called `ctx.teams.*` at all — `publish`/`reconcile` shipped in phase 2 unused. The probe needed a temporary passthrough. Not a defect in this PR, but `ctx.teams` is unexercised from a real module. --- - [x] AI-assisted: written with Claude Code (Claude Opus 5).
wtclaude added 5 commits 2026-08-18 02:05:22 +00:00
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.

Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.

Three rules shape the model:

  - core never composes a summary. It arrives already rendered and is stored
    verbatim; core cannot phrase "gained 15,000 gold" for a game whose
    vocabulary it does not know.
  - visibility fails closed. An item with no stated visibility is `members`.
  - a push never throws at its call site. It is called from inside a game-event
    handler, and a storage problem of core's must not become the module's
    control flow.

Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.

Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.

The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.

Co-Authored-By: Claude <noreply@anthropic.com>
TEAMS.md §3.3, as the eighth member of MODULE_API 1.6.0 — amended in place per
the org lead, on the rule Protocol 4 was given in phase 2: a contract owes a
bump only once it has landed on `main`.

Two questions meet on the roster and they belong to different owners. WHICH
ROWS a viewer may see is the module's, because the audience rungs and their
configuration live there and core does not know what a rung is. WHAT A ROW
LOOKS LIKE stays core's.

So `projectRoster` answers with member KEYS, not rows. §3.3 said rows, and rows
would let a module widen what is published — handing back a `userId` core had
withheld — leaving core's field guarantee resting on every module's good
behaviour. Core asks which rows and re-normalises the answer through its own
public shape, so a module can narrow and cannot widen.

"The module declines" needed splitting before it could be implemented. No
module at all and a module whose rungs could not be consulted are opposite
situations: the first withholds nothing and must serve the roster whole, the
second must serve none of it. The refusal carries `projects`, and only
`projects: true` fails closed. Without the split, bare core serves an empty
roster on every Team page.

This is also the first public route whose CONTENT depends on identity, which
needed a middleware core did not have. `attachSession` only decodes a token, so
a banned account, a password change or a logout would have kept working against
the private half of a feed until the JWT expired. `optionalAuth` runs
requireAuth's full database re-validation and, on any failure, continues
ANONYMOUSLY rather than rejecting — a caller whose session is no longer good
sees the public view, which is what they are entitled to.

`GET /public/teams/:slug/activity` lands here for the same reason: §2.11's route
table had no activity endpoint though §4.3 describes a filtered feed. Paged,
with the visibility resolved from the session and never from a parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
TEAMS.md §3.1–§3.5. Four core pages — the index, a Team's overview, its full
roster and the player portal's "My Teams" — plus the two extension slots a
module adds to them, and the nav rows that lead there.

These are CORE routes, not module ones. A Team is a core platform entity that a
module merely populates, so the whole experience renders on bare core; a module
adds to these pages rather than supplying them.

`team.member.row` is declared with `{ displayName, isLeader, linked }` and not
§3.4's `{ memberKey, userId, displayName }`. The two documents contradict each
other and §3.2 is the one that is a security rule: a slot component runs in the
browser, so those props can only reach it by publishing a game-internal
identifier and a site account id in every public roster response, for every
visitor, module installed or not. Recorded as an amendment.

The presentation logic is split into lib/teams.js with its own tests, following
lib/teamAdmin.js, because these pages have to state differences that read as
bugs unless they are worded deliberately:

  - "37 members · 21 linked" — the gap is information (a character with no site
    account behind it), and the header says what each number IS rather than
    showing both and hoping;
  - an empty roster has three unrelated causes — nobody in the Team, a rung
    that shows nobody, and a module that could not be asked — and reporting the
    last as the first is a statement about the game that happens to be false;
  - a stale projection says how old it is rather than presenting itself as
    current.

`teams` is the first CORE nav row to carry a `feature` since the shard rows left
with the module cutover, and it brings core's own feature provider back with it.
It gates on whether this deployment has Teams AT ALL, not on who is looking —
Team pages are public and the server gates them. It fails open, so an unknown
answer shows the link: a Teams link leading somewhere empty is a far cheaper
mistake than a Team page nobody can find.

Co-Authored-By: Claude <noreply@anthropic.com>
`npm run swagger` + `npm run routes:manifest` for the one added route,
`GET /api/v1/public/teams/:slug/activity`, and for `optionalAuth` joining
`/teams/:slug/members`.

The guards manifest names `optionalAuth` on both, which is the point of that
file: a reviewer can see that two public routes now read the caller's identity
without reading the routers.

Co-Authored-By: Claude <noreply@anthropic.com>
refactor(teams)!: Teams is a contract, not a surface — invert the slots
All checks were successful
PR Checks / bot-install (pull_request) Successful in 20s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 35s
5d9d10b245
Org lead's correction, and it changes what this phase ships.

TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.

Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.

Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.

**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.

Three things about the inverted direction are load-bearing:

  - the name is namespaced under the declaring module and that is enforced, not
    conventional: it is the only thing keeping two modules off one name;
  - core's fills are applied at MOUNT rather than eagerly. Core's bundle
    evaluates before every module chunk, so when core registers a fill the slot
    does not exist yet — filling eagerly would silently do nothing;
  - a fill for a slot nobody declared is a no-op, never an error. The declaring
    module is simply not installed, which is the ordinary case. That is the
    opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
    there, core declares first, so an unknown name is always a typo.

`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.

`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 7ed2ac9983 into edge 2026-08-18 02:11:08 +00:00
whitlocktech deleted branch feat/teams-phase3-pages-activity 2026-08-18 02:11:09 +00:00
Sign in to join this conversation.
No description provided.