feat(teams): answer core's Team provider from the guild board #9
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/teams-phase2-provider"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 (registerTeamProviderdoes 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.
externalIdis the persistent ServUOGuild.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.updatethat creates the board row — so there is a real window where a 155-member guild has zero roster rows. The board's ownmemberscount 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
rankLabelis 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.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 singleleader_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
onlinecomes fromshard_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.userIdprefers the roster's ownweb_id(what the shard asserted at roster time) and falls back to theshard_account_linksjoin for a row that predates the link. Resolved here rather than in core by contract: core readingshard_account_linkswould be core naming a module's table.coreApistays^1.3.0— 1.6.0 satisfies it, which is what makes the bump minor.fakeApigainedregisterTeamProviderwith core's ownoncerule, 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'sapp.jsis still being required with the pool pointed at a dead port, which bothrouteManifest.jsandswagger.jsdepend on.411 → 413 tests, all passing.
AI-assisted: written with Claude Code. Commits carry
Co-Authored-By: Claude <noreply@anthropic.com>.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>frozen-manifestis red, and it is supposed to be — here is what unblocks itserver-testsandclient-buildboth pass.frozen-manifestfails, and the cause is the pin doing exactly its job.That job clones core at the ref in
ci/core-ref.json— currently87230c8("edge @ phase 3 slice 4") — and runs core'srouteManifest.jswith this module dropped in. That core has noregisterTeamProvider(verified: zero occurrences in itsloader.js), soregister()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:
edge(that is whereregisterTeamProviderandctx.teamsland).ci/core-ref.jsonto that merge commit, regenerateroutes.manifest.json, commit both together as the pin's comment instructs.frozen-manifestgoes 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 ateamsfeature with nothing answering for it, which is precisely the state the envelope contract exists to make impossible.coreApiis the mechanism for "this module needs that core", and^1.3.0already 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 onmainrefs until the phase-10 cutover. Every phase from here adds contract surface this module uses, so the pin will need to trackedgefor the duration or this check stays red on every Teams PR.queryfrom the core facade, not acore.dbthat does not existThe 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>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
edgeand unreleased, so it has been amended in place rather than bumped — roster members now carryrank, and #10 makesgetTeamLeaders()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 ascore.db.query(...)and the facade has nodbmember — every other*.db.jshere destructuresqueryfrom 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.