feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
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?
Cutover 4 of 6, and the large one: 45 commits, ten phases of the Teams bet reaching
main. Design of record isdocs/website/TEAMS.md.A Team is a core platform entity with a durable identity, owned by the website; a game module is authoritative for who the Team is and who belongs to it; everything the platform attaches to a Team is core's. Discord is one optional consumer of that rather than the place Teams live.
What lands, by phase
registerTeamProvider/guildthrough it — definition in the module, execution dispatched over internal HTTP, because the bot container has nomodulesvolumedeclareModuleSlot's{ core }— core offers a contribution and never names a slotPhase 10 (the capability layer) is cancelled, deferred until a second integration is wanted. Phases 7–9 name Discord directly and that is deliberate; see TEAMS.md §8.2.
MODULE_API_VERSION1.6.0Nine additions, no removals, no changed signature.
module-uo'scoreApi: "^1.3.0"still resolves, and this merge is what puts 1.6.0 onmain— which is whatRunicGateway/Integration-kit'scheckCoreApipin needs before the kit can go green.Verification
1162 server tests · 288 client tests · OpenAPI spec and route manifests regenerated ·
checkModuleIdentifiersclean. Phases 3, 5, 7, 8, 9 and 11 were each walked on a live rig before their PRs opened; phase 11's walk stood the template module from the integration kit up in a real core and proved a module that is notmodule-uogets core's Team content.Merge after cutover 1–3 (the shard side and the installer), and before
Module-uo(5/6) anddocs(6/6).AI disclosure
Written with Claude Code (Opus 5). Commits carry the
Co-Authored-Bytrailer.The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5, §2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign keys rather than left to whatever the defaults happened to be. Every table is core-internal (§10.3): a module populates them through the team provider and must never read or write one directly. They carry no <moduleId>_ prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these are core's. team_forum_grants lands in this phase rather than in phase 4, so the four-path resolver is written once and its non-contamination tests are real. Nothing writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are phase 4's. Two departures from the SQL as TEAMS.md sketched it, both recorded in the file: - team_forum_grants.user_id is nullable with ON DELETE SET NULL, following §2.10 (the audit trail of who granted whom must survive the account) rather than §2.5's CASCADE. - its uniqueness marker is derived from revoked_at alone, with user_id moved into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET NULL on a foreign key whose column is a base column of a STORED generated column (error 1901). The semantics are identical -- at most one active grant per (team, user), unlimited revoked rows. Verified by running ensureSchema() against MariaDB 11: all six tables create, both generated columns materialise, and every foreign key's delete rule matches §2.10's table. The uniqueness encoding was checked directly -- a second active grant for the same (team, user) is rejected 1062 while revoked rows accumulate freely. Refs docs/website/TEAMS.md Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>The registration a module uses to become the authoritative source of Teams (docs/website/TEAMS.md §2.3), plus the wrapper core calls it through. registerTeamProvider is the first registration where core CALLS THE MODULE and waits for an answer. Every existing one is either the module claiming a mount or core notifying it; the closest precedent is registerAnnounceLeg's dispatch, and this is modelled on it rather than invented. It also holds a single value rather than a map, unlike every other registry: Teams have one authoritative source by construction, and two modules answering "what teams exist" would produce two disjoint sets under one `teams` table with no rule for merging them. A second registration is therefore a collision, named against the module that holds it. teamProvider.js is where invariant 1 -- module unavailability is staleness, never emptiness -- is actually enforced. It is deliberately generous about what counts as a failure: a rejected promise, a synchronous throw, a timeout, a non-object, a bare array, a missing `ok`, or a structurally malformed row all leave as the same `{ ok: false }` a module would have sent on purpose. There is no shape a broken provider can produce that arrives at the reconciler looking like an authoritative empty list -- which is the entire argument for the envelope, since a bare array has exactly one such shape and it is the one a module returns while its sidecar is still connecting. A malformed row fails the whole call rather than being dropped. Salvaging is the dangerous option: one unreadable member quietly omitted from a roster is indistinguishable, downstream, from that member having left, and the sync would mark them departed on the strength of a broken payload. Refusing costs one stale interval. The deadline timer is unreffed as well as cleared. Clearing covers the case where the race settles; it cannot cover a module promise that never settles at all, where nothing exists to clear until the deadline fires. Caught by the test file taking 10.2s to run 265ms of assertions -- the same class of bug as the mariadb pool that used to hold the suite open (test/_setup.js). 292ms now. 28 tests. Full suite 770 passed, 0 failed. Refs docs/website/TEAMS.md §2.3, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md §2.4), plus the two ctx members a module pushes through. The four gates are the file, and each is invariant 1 in a different costume -- module unavailability is staleness, never emptiness: 1. getTeams() not ok -> record the failure, touch NOTHING, return. 2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT authoritative answer, an interval later, agrees. 3. getTeamMembers() not ok -> that Team's roster untouched and stale; the other Teams sync normally. 4. ok but zero members, had some -> the same two-strikes quarantine, per Team. Gates 2 and 4 exist because an authoritative-looking empty answer during a cold start is the one failure indistinguishable from a real wipe. "Every Team on the shard disbanded at once" costs one interval to confirm; getting it wrong empties every roster on the site. Events are an optimisation, never the source of truth. Member and leadership deltas apply at once for a Team core already knows; team.created and team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full list, so a repeated or spurious disband event costs a reconcile rather than a Team -- and a Team invented from a delta would have no name, no roster and no leaders anyway. Two columns TEAMS.md did not contemplate, both on `teams`: - roster_synced_at, because team_sync_state holds one row per MODULE and gate 3 leaves ONE Team behind while the others sync. Without a per-Team stamp that Team's page would report the module's last success as its own -- exactly the staleness the gate exists to surface. - members_empty_since, gate 4's per-Team quarantine. The twin of team_sync_state.pending_empty_since, which is per module and cannot express it. One real bug found by its own test. The roster upsert was writing is_leader, so a refused getTeamLeaders() left every member demoted -- the roster had already written `leader: false` before the authoritative call was even made. §2.5 is explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on INSERT only (seeding a Team so it is not leaderless while that call fails) and moved afterwards by setLeaders() alone. Two writers for one column was the whole defect. MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module declares one coreApi range. The number covers the whole Team surface per Part 11; the members arrive by phase. registerTeamProvider, ctx.teams.publish and ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence naming their phase, rather than being absent or silently accepting data into tables that do not exist yet. 39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it caught the addition, which is what it is for. Server 809 passed, client 192 passed, 0 failed. Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>The one place untrusted game data becomes a public page (docs/website/TEAMS.md §2.8), and the gate on releasing it (§2.9). A Team's name is written by a player, in the game, with no review, and this platform turns it into a public page, a URL and eventually a Discord channel name. Someone naming their guild "Admin" or "<Brand> Staff" gets an official-looking page on the operator's own site for free. Hide, never reject. Core cannot refuse a name -- the guild already exists in the game and core is a mirror of it, not an authority over it. A match hides the Team from public surfaces and files it in a review queue, and it keeps working completely for its own members: their forum, their grants, their notifications. The people in it are not being punished for a name their leader chose. That asymmetry -- a false positive costs a human glance, a false negative costs an impersonated staff page -- is what lets the matcher be conservative. It is not licence to be sloppy the other way: a check that fires on "Badminton" gets switched off, and then the real cost is paid in full. So matching is whole WORDS after normalisation, never substrings, following the precedent scripts/checkModuleIdentifiers.js set for exactly this reason. Three matcher gaps found by writing the tests, all real impersonation vectors: - "Guild of Moderators" did not match `moderator`. Only a trailing s off the WHOLE term is stripped, so "Nomads" still does not match `mod`. - "G.M." normalises to two single-letter words and matched nothing. A run of two or more single-letter words is now also offered joined. Deliberately not a whole-name condensation, which would re-admit substring matching. - The multi-word condensed form was already handled and is what makes "RunicGateway" match the two-word term -- the form an impersonator would reach for, since it is what the Gitea org and every URL use. Terms resolve at CHECK time, never baked in, so renaming a deployment protects the new name without a redeploy. A failed settings read falls back to the static role and project terms rather than to an empty list: screening fewer terms is bad, screening none is the whole hole. Re-screening runs on every reconcile, over names no human has ruled on. Names are immutable per row, so it only ever changes an outcome when the TERM LIST changed -- an operator adding one, or a rename -- which is exactly what a create-time-only check would miss forever. `name_reviewed_at` is what makes a staff decision sticky; without it an override would be undone every fifteen minutes. The gate is scoped to three actions because they publish untrusted game-sourced strings, and to nothing else. Ordinary forum grants, leadership overrides, archives and forum moderation still apply immediately and are audited. A moderator initiating one files a pending request; an admin applies at once. Never four-eyes on admins: users.role defaults to admin and `npm run seed` creates exactly one, so most deployments have precisely one and a second-approver rule would wedge them with no way out. Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair of eyes; withdrawing it needs to be possible at once, by whoever is on duty. Two concurrency details worth the review: a decision moves the row out of `pending` under a guard and applies its effect only if the row actually moved, so two admins clicking approve cannot double-apply or overwrite each other's record; and a JSON payload is parsed defensively, because the driver returns JSON columns already parsed on some versions and as a string on others. Screening is stubbed in the reconciler's own tests -- it is a separate unit, and the real call reads settings, which this suite must never do against a live database. That was caught the hard way: the suite went from 11s to hanging, and the cause was the reconciler reaching a dead pool through the new call. 44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the gate. Full suite 877 passed, 0 failed. Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>The eighteen routes of docs/website/TEAMS.md §2.11, their OpenAPI annotations, and the staff screen that drives them. Two rules shape the read model. Hidden means absent from every public surface -- the index, the lookup and the roster alike, and a hidden Team 404s indistinguishably from one that does not exist, because "absent" includes not confirming it is there. And staleness is surfaced rather than silent: every public payload carries { configured, stale, lastSyncAt }, so a page can say how recently the projection was confirmed instead of presenting stale data as current. The public roster withholds both the member key and the user id -- one is a game-internal identifier, the other names a site account. `linked` answers the only question a public page has without publishing which account. The module's per-audience field projection is phase 3's; this is a conservative core one. The §2.9 gate is enforced per REQUEST, not per route. A moderator may call all eighteen; three of them mean something different when they do, and the server decides from the role it re-validates on every request rather than from a token claim. The client has no "file as request" argument to get wrong. Found by booting the real server against the real database, and not by any test: **the index and the by-slug lookup disagreed about what exists.** listPublic was keyed on a registered team provider while findBySlug is not, so with no module installed `/teams` returned an empty list while `/teams/:slug/members` served a full roster -- the index denying a Team that direct URLs answered for in full. The rows are core's and they outlive the module that filled them: an uninstalled module leaves a projection that is unmaintained, not one that stopped existing, and `configured: false` is how a client learns that. The read side no longer takes the provider into account at all. There is now a test named for the property. Also verified live: the public routes answer anonymously, an unknown and a hidden slug both 404, the player and admin tiers 401 an anonymous caller, a seeded roster projects correctly, and the reconciler logs that it is staying idle with no provider registered rather than failing a boot. Process obligations, all done: #swagger.* annotations on every route, `npm run swagger` regenerated (18 paths in the spec, no dangling $refs, and the schemas they reference added), `npm run routes:manifest` regenerated -- additions only, 184 public routes -- and BACKEND_DESIGN.md updated across the schema section and all three tier tables. Admin -> Teams follows the ModulesAdmin precedent: everything that decides what a row SAYS lives in lib/teamAdmin.js, which is plain JS with tests, and the view renders it. That split earns itself here specifically -- the screen's job is to make "the shard has no Teams" and "core has not been able to ask for two hours" impossible to confuse, and those two produce the same empty table. The four freshness states are named and tested for exactly that reason, and the last provider error is shown verbatim rather than paraphrased. The button labels follow the caller's role: a moderator sees "Request publish", so the pending result is not a surprise. Hiding is offered to everyone with no gate, matching the server. Server 894 passed, client 206 passed, client build clean. 17 route tests, 20 client display tests. Refs docs/website/TEAMS.md §2.11, Part 12 phase 2 Co-Authored-By: Claude <noreply@anthropic.com>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.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>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>Path 3's WRITE half. The resolver landed in phase 2; this is who may hand access out, to whom, and what stops a leader turning a Team forum into open hosting on the operator's site. Two authorities, and not one authority with different reach. Staff may act on any Team, uncapped, and may revoke anything. A leader may grant and revoke ordinary access on their own Team, is capped at `teams_max_grants_per_team` (default 50), is rate-limited, and may NOT revoke a staff-issued grant — which is what stops a leader undoing a moderation decision. The issuer's role is checked at revoke time rather than stored, so an account that has since lost its staff role stops protecting the grants it made. Nothing on this path writes team_members, in either direction. A grant may name any account, including one with no linked game identity — that is the point of it — and that account stays off the roster, out of every count, and ineligible for external platforms. Announcements are a degenerate thread rather than their own object, so phase 5 adds no migration. Moderation records WHICH authority was exercised: a staff action also writes activity_log, a leader's writes only the Team's own ledger. Merging the two would make a guild leader locking a thread an appealable Discord sanction. Every forum route answers 404 while the switch is off, and 404 — never 403 — to a caller with no access: in a private room the contents and the existence are the same secret. The grant routes deliberately answer even while the forum is OFF, because a toggle-off revokes no grant and the access list has to stay manageable. Under /player rather than /admin: a leader is a player, and the /admin tier gate is requireRole('admin','editor','moderator') — putting a leader endpoint behind it would mean widening that gate. Co-Authored-By: Claude <noreply@anthropic.com>Phase 5's server half — TEAMS.md §5.1's "5b". The schema for all of it landed in phase 4, so this adds no ALTER: every column it needed (`type`, `locked`, `edited_at`, `edited_by`, the post table's `status`, the ledger's `target_type='post'`) was already there waiting. * `teams_forum_edit_window_minutes` (0…1440, default 15) joins the forum's settings. It fails closed to ZERO rather than to its default, which is the opposite of what it looks like it should do: the risk an edit window bounds is an author rewriting a post out from under a reader quoting it or a moderator about to act on a report, so the safe answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement freezes this key too — it is a forum setting. * Thread creation splits its authority BY TYPE, which is what phase 4's comment said would happen here rather than widening the leader gate. An announcement stays leader-authored; a discussion is open to every participant, and "participant" includes a granted non-member with no game identity — path 3 doing its job. `type` still defaults to `announcement`, so a phase-4 client keeps meaning what it meant. * Replies refuse three ways with deliberately different codes: 404 for absent or hidden, 400 for an announcement (which takes no replies by TYPE, not by being closed), and 409 for locked — well-formed request, refusing state. Locked refuses staff too; they hold `unlock`, and unlock/post/relock reaches the same place leaving three ledger rows that say so. * The edit window is evaluated on the server twice, on purpose. The read path stamps every post with `canEdit`/`editableUntil` so the client knows whether to draw the control; the write re-derives it from `created_at` before allowing anything. A time-bounded permission must not take its clock from the party it bounds. Staff are not time-bounded, and a staff edit of someone else's words writes `activity_log` while a member fixing their own typo does not (§5.3). * Post moderation shares the thread ledger via `target_type='post'`, so "everything moderated in this Team" stays one query. `pin`/`lock` are refused by name rather than as unknown actions — they describe a thread's place in a list and its openness to replies, neither of which a post has. Counters are RECOMPUTED after each action rather than nudged, because hide → unhide → hide is a cycle a delta gets wrong the first time a step is retried. Two fixes to phase 4 code this work reached: `softDeleteUploadsForPost` bound its two arguments in the wrong order (never fired — nothing called it until post deletion did), and it had no inverse, so `delete` → `restore` would have returned a post's words and silently lost its pictures a retention window later. Co-Authored-By: Claude <noreply@anthropic.com>1008 pass (972 before). The tests worth reading first are the ones that pin a property no screen would look different without: * **The edit window is decided on the server, twice.** One test proves the read path stamps `canEdit` per post per viewer; another proves the WRITE path re-derives it from `created_at` and refuses a stale edit even though the client was told it could — because a time-bounded permission must not take its clock from the party it bounds. * **A locked thread refuses staff too**, asserted over member, leader and staff in one loop, at 409 rather than 403: well-formed request, refusing state. * **delete → restore is reversible for images.** Without the second half of the pair a restored post returns its words and loses its pictures a retention window later, silently — the test asserts both calls and that `hide` makes neither. * **Post moderation recomputes the thread's counters** rather than nudging them; the test runs hide → unhide → hide, which is the cycle a delta gets wrong. * **acceptance: nothing in the report model is reachable by a Team leader.** The negative property is the whole point of §5.6 and negatives are what nobody notices going, so it is asserted directly — the module's function surface is pinned, and `queue`/`handle` are checked not to mention leadership at all. If a leader-facing queue is ever wanted it is the org lead's decision, and this test is what makes somebody ask. * **A report never changes the content it is about**, proved by stubbing every mutation the forum has to throw. If filing a report touched a status then "report" would BE moderation, and the first person to work that out would have found a way to hide anything on the site. The test suite caught one real defect: `describeTarget` returned `undefined` for a hard-deleted target, and `undefined` is dropped by JSON.stringify — so the documented `target: null` would have reached clients as an absent key. Two phase-4 tests were updated rather than added to, both because phase 5 changed what they describe: `canPost` split into `canPost` (open a discussion, everyone) and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type. Phase 5's four new player routes are added to acceptance criterion 2's list, so "with the forum off every forum route 404s" keeps covering the whole surface. Co-Authored-By: Claude <noreply@anthropic.com>241 client tests pass (224 before). **The forum panel becomes a forum.** It was "Announcements" with one composer; it now has two, because phase 5 split one server capability into two: `canPost` means "may open a discussion" and every participant may — a granted guest with no game character included, which is path 3 doing its job — while `canAnnounce` is the leader-only half `canPost` used to carry alone. Threads gain replies, an edit control, per-post moderation and a report control, all still inside the one slot the module declares, still navigating by `?thread=`. **Almost nothing here is the client's decision, and the file says so.** `canPost`, `canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer whose deadline passed while the page sat open — it can never grant one, because a time-bounded permission must not take its clock from the party it bounds. That asymmetry is the first thing client/test/teamForum.test.js asserts. The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than they look: * `stripToText` decodes entities AFTER stripping tags, and `&` last of all. Decoding first turns an author's literal "<script>" into a real tag the strip pass then deletes — silently losing text that was never dangerous. * `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing the raw count tells a reader a brand-new thread already has one reply. **Three admin surfaces.** The forum settings screen gains the edit-window field (0 = posts permanent once written). The reports queue is a new screen beside Appeals — under moderation rather than under Teams, because a staffer working a queue should have one place to work and `target_type` is deliberately open-ended, so the next reportable thing arrives as a row rather than as another nav entry. Its copy tells a member where a report lands and that reporting changes nothing, because a member who expects a post to vanish and watches it stay reports it again. There is no leader-facing view and there is not meant to be. And the per-Team forum moderation ledger finally renders: the route and `api.admin.teamForumModeration()` have both existed since phase 4 with nothing calling them, which made `actor_role` — the column that keeps a leader's ordinary housekeeping distinguishable from a staff intervention — readable only from a DB client. Co-Authored-By: Claude <noreply@anthropic.com>Found walking the live rig, which is the only place it could be found: every unit test and the settings screen itself send every row, so the bug was invisible to both. `PUT /auth/me/notifications/teams` documents itself as replacing the whole set. It did not — it wrote the entries it was given and left every other preference standing. So `{"teams": []}` cleared nothing, which is precisely the body the route requires the array for: the field is mandatory even when empty so that clearing everything is expressible, and it was the one thing that did not work. A Team the caller could have named and did not now returns to its defaults. RESET rather than deleted, and the difference is `last_digest_at`: that column is the digest worker's state and not a preference, so dropping the row with it would make every visit to the settings screen re-open a day-wide digest window and mail somebody a summary they had already read. Walked again after the fix on the real database: the empty set clears, an entry naming a Team the caller is not in is still dropped, and the digest stamp survives. Co-Authored-By: Claude <noreply@anthropic.com>TEAMS.md §7.3. Each qualifying Team gets a Discord voice channel of its own and a role that opens it, kept in step by a reconciler that rides the Team reconcile it already depends on. Access is a per-Team ROLE, always. §7.3 designed per-member overwrites with escalation to a role above ~90 members; the org lead settled on roles always (2026-08-18), which deletes `voice_overwrite_max`, the escalation and the `mode` column — and moves the ceiling. Overwrites are capped per channel, so the old shape's limit was "how big can one Team be"; roles are capped per guild at 250, so the new one is "how many Teams can have voice at all". That is a limit an operator must be told about before they hit it, so the panel reports it and the pass refuses the create rather than letting Discord do it. Three things §7.3 named that this codebase does not have, all settled by asking the operator because nothing in the data model can answer: - "the staff role" — there is no staff-role concept anywhere. Now a list of role ids the admin designates; empty is a normal answer, since guild administrators bypass overwrites and what is really missing is a way to let NON-admin staff in. - the parent category — §7.3 said the bot creates it and gave the id nowhere to live (`team_integrations.team_id` is NOT NULL). The bot creates it and the server stores the id in settings. - whether the bot can act at all — nothing has ever checked. The operator invites the bot by hand and no invite URL with a permission integer exists in the tree, so a deployment can be one unticked box from every call failing. A preflight is now a PRECONDITION to enabling (422), not a per-Team error discovered afterwards. Two more, decided rather than asked: - the threshold counts every active member, not linked ones. §7.3 wrote `voice_min_linked_members`; the operator is judging whether a Team is real, and link state answers a different question. - hidden Teams are never provisioned. A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern — reservedNames.js already names "and eventually a Discord channel name" as a surface it protects — so the screen that suppresses a Team's page suppresses its channel, and a Team that becomes hidden takes the grace window. Turning voice OFF tears nothing down: the pass suspends in both directions and the panel offers per-row removal. A checkbox must not delete structure in somebody's guild. Fixes a phase 8 defect that blocks this phase's own artifact: `npm run swagger` has been unable to run on `edge` at all. `param('teamId').custom((v) => ... || /^[0-9]+$/.test(v))` makes swagger-autogen's parser run away — a regex literal followed directly by `.test(`. Hoisted to a const, as modules.router.js already does. Underneath it, `teams.router.js` sits exactly at that parser's per-file limit: at twenty `teamsRouter.*` statements it dies, at nineteen it generates, and one more statement of ANY shape tips it — an unannotated route does, and so does a bare `use`. So the voice routes are their own router file mounted from `admin/index.js`, and teams.router.js keeps its nineteen. Also breaks a require cycle this phase would have introduced: teamSync -> teamVoiceSync -> teams.model -> teamSync left `teams.model` holding the reconciler's exports object as it stood mid-load — the empty one, since `module.exports = {…}` replaces rather than fills. The symptom is not in the new code: it is `teamSync.intervalSeconds is not a function` thrown out of `syncStatus()`, the freshness banner on every public Team page. Tests: 1160 server (+40), 53 bot (+21), 284 client (+21). Swagger, routes manifest and guards regenerated; the guard shape of the four new routes is byte-identical to the existing admin-only ones. Co-Authored-By: Claude <noreply@anthropic.com>Walked against real MariaDB, the real app, and a fake standing in for Discord that mounts the bot's real internal routes — everything up to the Discord API call was production code. 47 assertions, and it found two things every unit test in the phase had passed over. 1. **Every query failed: two result columns named `team_id`.** `desiredTeams` and `holdersWithoutClaim` both select `t.id AS team_id`, and the shared column list added `i.team_id` beside it. The `mariadb` driver refuses a result set with a repeated field name outright, so the pass died at its first query with "Error in results, duplicate field name `team_id`" — on the one code path every unit test stubs. It was also the wrong column: `desiredTeams` LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel yet, which is the create case. The two queries that do not join `teams` now ask for it by name. The regression test checks the INTERPOLATED sql captured from a fake `query`, not the source text — in the source the shared list is still a `${COLUMNS}` placeholder, and a first attempt that read the file passed happily with the bug reintroduced. 2. **"Sync now" said "Nothing was done" while it was doing it.** Saving the settings with voice switched on asks for a pass. An operator who then presses Sync now — the obvious next thing — hit `running` and got back `ran: false, reason: "a pass is already running"`, which the panel renders as nothing having happened, while the pass they triggered was busy creating their channels. A pass in flight is now JOINED and its real outcome returned, the same choice `teamSync.reconcileNow` makes for the same reason. Tests: 1162 server (+2), 53 bot, 284 client. Co-Authored-By: Claude <noreply@anthropic.com>The inverted slot direction reached exactly one module. Core filled three literal names - uo.guild.detail, uo.guild.forum, uo.guild.header - matched by exact name in applyCoreFills, so a second game declaring a place under its own id got an empty page and no error. "A fill for a slot nobody declared is not an error" is the rule that made the miss invisible, and it is the right rule; what was wrong was core knowing a slot's name at all. It also put a module identifier inside core, in three string literals scripts/checkModuleIdentifiers.js masks by construction and could never catch. Found by the integration kit while writing the chapter that teaches this shape to an audience outside this org - which is what that phase is for. So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there: declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' }) and core offers into the catalogue rather than into a name: offerCoreFill('team.activity', TeamActivityFeed) CORE_CONTRIBUTIONS is exported and fixed at build time, so asking for one core does not offer THROWS at the declaration. That asymmetry with an unfilled slot is deliberate: an unknown contribution is always a typo or a version skew - the module's coreApi range has already been checked - and the failure it would otherwise produce is a page that renders empty forever with nothing logged. options.core is optional; a slot that asks for nothing stays empty, which is what a module declaring a place it fills itself wants. More than one slot may ask for the same contribution and each gets it: how many places a module wants its feed in is a layout decision on a page core does not own. Amends MODULE_API 1.6.0 in place rather than adding 1.7.0 - the same rule the eighth and ninth members were given, and 1.6.0 has only ever been on edge. Also: the UI kit is nine exports, not eight. Slot made it nine in phase 3 and the comment beside it still said eighth. 288 client tests, 1162 server tests. Co-Authored-By: Claude <noreply@anthropic.com>