feat(teams): answer core's Team provider from the guild board #9

Merged
whitlocktech merged 1 commits from feat/teams-phase2-provider into edge 2026-08-17 22:48:21 +00:00
Member

Teams Phase 2, the module half. Targets edge.

Plan: docs/website/TEAMS.md §2.3 · pairs with website #151 and docs #154 · needs website #151 merged first (registerTeamProvider does not exist before it)

What

A UO guild is a Team. This registers module-uo as the authoritative source of them (MODULE_API 1.6.0) and answers the three questions core asks, from the board and the roster Protocol 4 put there in phase 1.

externalId is the persistent ServUO Guild.Id, which survives a rename — so core sees "an id whose name changed" and applies its rename rule, rather than an unrelated new guild appearing beside the old one. Only the game knows what identity survives what, so that mapping is this module's to make.

The refusal guard is the important part

Core's contract is that module unavailability becomes staleness, never emptiness, and this module is the only thing that can honour it: an empty array from here reads as an authoritative "there are none", and core archives Teams and departs members from an authoritative answer.

Three states refuse — no uo-link configured, the integration disabled, and the socket not connected.

The third is the one worth arguing about. The board is durable and survives an outage, so serving it while disconnected looks harmless. It isn't: core cannot tell a board five minutes stale from one five days stale, and a complete answer licenses destruction. There's a test named for that.

A fourth refusal has no equivalent anywhere else. Protocol 4's roster arrives on its own frames, separately from the guild.update that creates the board row — so there is a real window where a 155-member guild has zero roster rows. The board's own members count is the only thing that distinguishes "the roster is late" from "this guild is empty", and it is checked, with the count in the refusal message because it is the evidence. The other side is tested too: when the board says zero, an empty roster is the truth, and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest

  • rankLabel is null. The wire's roster member is the standard actor object (serial, name, player, acct?, webId?) and carries no guild rank. Inventing a label from the leader flag would be core displaying something this module made up.
  • One leader, not several. TEAMS.md §2.5 expects multiple leaders from GuildRank.Rank >= 4, and core supports them — but Protocol 4 does not put rank on the wire, so the only leadership visible here is the board's single leader_serial. Raising it to the full set is a protocol change, not something this module can fix. Worth deciding whether that belongs in a later phase.

Notes

  • online comes from shard_online, not the roster — the roster carries no per-member presence and the board only a count. Same source the public "who's online" surface already uses.
  • userId prefers the roster's own web_id (what the shard asserted at roster time) and falls back to the shard_account_links join for a row that predates the link. Resolved here rather than in core by contract: core reading shard_account_links would be core naming a module's table.
  • coreApi stays ^1.3.0 — 1.6.0 satisfies it, which is what makes the bump minor.
  • fakeApi gained registerTeamProvider with core's own once rule, so a second registration fails in this suite rather than at load on an operator's install.

Two entry-point tests worth a look: that all three methods are registered, and that registration performs no queryregister() runs while core's app.js is still being required with the pool pointed at a dead port, which both routeManifest.js and swagger.js depend on.

411 → 413 tests, all passing.


AI-assisted: written with Claude Code. Commits carry Co-Authored-By: Claude <noreply@anthropic.com>.

Teams **Phase 2**, the module half. Targets `edge`. Plan: `docs/website/TEAMS.md` §2.3 · pairs with website #151 and docs #154 · needs website #151 merged first (`registerTeamProvider` does not exist before it) ## What A UO guild is a Team. This registers module-uo as the authoritative source of them (MODULE_API **1.6.0**) and answers the three questions core asks, from the board and the roster Protocol 4 put there in phase 1. `externalId` is the persistent ServUO `Guild.Id`, which survives a rename — so core sees "an id whose name changed" and applies its rename rule, rather than an unrelated new guild appearing beside the old one. Only the game knows what identity survives what, so that mapping is this module's to make. ## The refusal guard is the important part Core's contract is that module unavailability becomes **staleness, never emptiness**, and this module is the only thing that can honour it: an empty array from here reads as an authoritative "there are none", and core archives Teams and departs members from an authoritative answer. Three states refuse — no uo-link configured, the integration disabled, and the socket not connected. **The third is the one worth arguing about.** The board is durable and survives an outage, so serving it while disconnected looks harmless. It isn't: core cannot tell a board five minutes stale from one five days stale, and a *complete* answer licenses destruction. There's a test named for that. A fourth refusal has no equivalent anywhere else. Protocol 4's roster arrives on its own frames, separately from the `guild.update` that creates the board row — so there is a real window where a 155-member guild has zero roster rows. **The board's own `members` count is the only thing that distinguishes "the roster is late" from "this guild is empty"**, and it is checked, with the count in the refusal message because it is the evidence. The other side is tested too: when the board says zero, an empty roster is the truth, and withholding it would freeze a disbanding guild's membership forever. ## Two limitations, both honest - **`rankLabel` is null.** The wire's roster member is the standard actor object (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank. Inventing a label from the leader flag would be core displaying something this module made up. - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from `GuildRank.Rank >= 4`, and core supports them — but Protocol 4 does not put rank on the wire, so the only leadership visible here is the board's single `leader_serial`. Raising it to the full set is a **protocol change**, not something this module can fix. Worth deciding whether that belongs in a later phase. ## Notes - `online` comes from `shard_online`, not the roster — the roster carries no per-member presence and the board only a count. Same source the public "who's online" surface already uses. - `userId` prefers the roster's own `web_id` (what the shard asserted at roster time) and falls back to the `shard_account_links` join for a row that predates the link. Resolved **here** rather than in core by contract: core reading `shard_account_links` would be core naming a module's table. - `coreApi` stays `^1.3.0` — 1.6.0 satisfies it, which is what makes the bump minor. - `fakeApi` gained `registerTeamProvider` with core's own `once` rule, so a second registration fails in this suite rather than at load on an operator's install. Two entry-point tests worth a look: that all three methods are registered, and that **registration performs no query** — `register()` runs while core's `app.js` is still being required with the pool pointed at a dead port, which both `routeManifest.js` and `swagger.js` depend on. **411 → 413 tests, all passing.** --- AI-assisted: written with Claude Code. Commits carry `Co-Authored-By: Claude <noreply@anthropic.com>`.
wtclaude added 1 commit 2026-08-17 20:33:38 +00:00
feat(teams): answer core's Team provider from the guild board
Some checks failed
PR Checks / client-build (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 21s
PR Checks / frozen-manifest (pull_request) Failing after 44s
268449f2a6
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.

`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.

The most important code here is the refusal guard, and it is deliberately
conservative. Core's contract is that module unavailability becomes staleness and
never emptiness, and this module is the only thing that can honour it -- an empty
array from here reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.

**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.

A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes on its own frames, separately from the
`guild.update` that creates the board row, so there is a real window where a
155-member guild has zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.

Two limitations, both honest and both in the code as comments:

  - **`rankLabel` is null.** The wire's roster member is the standard actor object
    (`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
    Inventing a label from the leader flag would be core displaying something this
    module made up.

  - **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
    `GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
    on the wire, so the only leadership visible here is the board's single
    `leader_serial`. Raising it to the full set is a protocol change, not
    something this module can fix.

`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.

`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.

18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.

`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.

411 -> 413 tests, all passing.

Refs docs/website/TEAMS.md §2.3, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
Author
Member

frozen-manifest is red, and it is supposed to be — here is what unblocks it

server-tests and client-build both pass. frozen-manifest fails, and the cause is the pin doing exactly its job.

That job clones core at the ref in ci/core-ref.json — currently 87230c8 ("edge @ phase 3 slice 4") — and runs core's routeManifest.js with this module dropped in. That core has no registerTeamProvider (verified: zero occurrences in its loader.js), so register() throws before a manifest can be generated.

So the red X is not a defect in this PR. It is the pin's stated purpose: "a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR."

The order, therefore:

  1. Merge website #151 into edge (that is where registerTeamProvider and ctx.teams land).
  2. Bump ci/core-ref.json to that merge commit, regenerate routes.manifest.json, commit both together as the pin's comment instructs.
  3. frozen-manifest goes green and this merges.

What I deliberately did not do: guard the call with typeof api.registerTeamProvider === 'function'. That would turn a version mismatch into a module that silently registers no Team provider — core would then hold a teams feature with nothing answering for it, which is precisely the state the envelope contract exists to make impossible. coreApi is the mechanism for "this module needs that core", and ^1.3.0 already resolves 1.6.0 correctly; the pin is the mechanism for "this module was proved against that core". Neither should be worked around at runtime.

Worth a second opinion on one thing: whether the pin should move to the website#151 merge commit on edge, or stay on main refs until the phase-10 cutover. Every phase from here adds contract surface this module uses, so the pin will need to track edge for the duration or this check stays red on every Teams PR.

## `frozen-manifest` is red, and it is supposed to be — here is what unblocks it `server-tests` and `client-build` both pass. `frozen-manifest` fails, and the cause is the pin doing exactly its job. That job clones core at the ref in `ci/core-ref.json` — currently `87230c8` (*"edge @ phase 3 slice 4"*) — and runs **core's** `routeManifest.js` with this module dropped in. That core has **no `registerTeamProvider`** (verified: zero occurrences in its `loader.js`), so `register()` throws before a manifest can be generated. So the red X is not a defect in this PR. It is the pin's stated purpose: *"a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR."* **The order, therefore:** 1. Merge **website #151** into `edge` (that is where `registerTeamProvider` and `ctx.teams` land). 2. Bump `ci/core-ref.json` to that merge commit, regenerate `routes.manifest.json`, commit both together as the pin's comment instructs. 3. `frozen-manifest` goes green and this merges. **What I deliberately did not do:** guard the call with `typeof api.registerTeamProvider === 'function'`. That would turn a version mismatch into a module that silently registers no Team provider — core would then hold a `teams` feature with nothing answering for it, which is precisely the state the envelope contract exists to make impossible. `coreApi` is the mechanism for "this module needs that core", and `^1.3.0` already resolves 1.6.0 correctly; the pin is the mechanism for "this module was proved against that core". Neither should be worked around at runtime. Worth a second opinion on one thing: whether the pin should move to the website#151 **merge commit on `edge`**, or stay on `main` refs until the phase-10 cutover. Every phase from here adds contract surface this module uses, so the pin will need to track `edge` for the duration or this check stays red on every Teams PR.
wtclaude added 1 commit 2026-08-17 22:41:27 +00:00
fix(teams): take query from the core facade, not a core.db that does not exist
Some checks failed
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Failing after 40s
c6929c6bae
The Team provider's db layer built its query helper as `core.db.query(...)`. The
facade has no `db` member -- every other *.db.js in this module destructures
`query` from it directly -- so every call threw `Cannot read properties of
undefined (reading 'query')`.

The failure mode is the bad part. That throw is caught by the provider's own
error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`,
which is a perfectly valid refusal -- so core would have accepted it, held the
projection it had, and reported staleness. A provider that answers correctly and
never returns data, forever, with nothing in any log louder than a warning.

Invisible to the unit tests because they stub every db function, so the helper
was never called. Found by running a real roster frame through the ingest and
then asking the provider what it saw, against the real database.

Co-Authored-By: Claude <noreply@anthropic.com>
Author
Member

Two corrections to this PR's description

1. "One leader, not several" is no longer a standing limitation. I described it here as a protocol change that this module could not fix. Protocol 4 is still on edge and unreleased, so it has been amended in place rather than bumped — roster members now carry rank, and #10 makes getTeamLeaders() return every member at rank 4. servuo-plugins #13 emits it, docs #155 specifies it.

The limitation was real when this PR was written; treat it as resolved by the stack rather than as something to accept.

2. A latent bug was found and fixed on this branch (c6929c6). The db layer built its query helper as core.db.query(...) and the facade has no db member — every other *.db.js here destructures query from it directly. Every call threw.

The failure mode is why it is worth calling out: the throw was caught by the provider's own error handling and turned into { ok: false, reason: 'roster unreadable: …' }, which is a perfectly valid refusal. Core would have accepted it, held its projection and reported staleness — a provider that answers correctly and never returns data, forever, with nothing louder than a warning in the log.

Invisible to every test in this PR, because they stub each db function individually so the helper was never called. Found by feeding a real roster frame through the ingest and asking the provider what it saw, against the real database — which is now how the round trip is verified in #10.

Merge order for the stack: #9#10, and servuo-plugins #13 + docs #155 alongside.

## Two corrections to this PR's description **1. "One leader, not several" is no longer a standing limitation.** I described it here as a protocol change that this module could not fix. Protocol 4 is still on `edge` and unreleased, so it has been amended in place rather than bumped — roster members now carry `rank`, and #10 makes `getTeamLeaders()` return every member at rank 4. servuo-plugins #13 emits it, docs #155 specifies it. The limitation was real when this PR was written; treat it as resolved by the stack rather than as something to accept. **2. A latent bug was found and fixed on this branch** (`c6929c6`). The db layer built its query helper as `core.db.query(...)` and the facade has no `db` member — every other `*.db.js` here destructures `query` from it directly. Every call threw. The failure mode is why it is worth calling out: the throw was caught by the provider's own error handling and turned into `{ ok: false, reason: 'roster unreadable: …' }`, which is a perfectly valid refusal. Core would have accepted it, held its projection and reported staleness — a provider that answers correctly and **never returns data, forever**, with nothing louder than a warning in the log. Invisible to every test in this PR, because they stub each db function individually so the helper was never called. Found by feeding a real roster frame through the ingest and asking the provider what it saw, against the real database — which is now how the round trip is verified in #10. Merge order for the stack: **#9 → #10**, and servuo-plugins #13 + docs #155 alongside.
whitlocktech merged commit 76b2321f25 into edge 2026-08-17 22:48:21 +00:00
whitlocktech deleted branch feat/teams-phase2-provider 2026-08-17 22:48:22 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Module-uo#9
No description provided.