ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front, the audience problem that shaped the phase, why scope_key is not subject_key, the one place decision 4 as phrased could not ship, the projection's rule and what it refuses to guess, the digest correcting Phase 4a rather than only implementing §4.2b, and the three defects the build found. §4.2b and §6.0b updated with it. TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own thing. It now says which of the three sinks moved and which did not, where each of the four properties it always claimed lives now, that Team email is OFF until an operator turns a rule on, and what the generalized unsubscribe token does. §6.3's `last_digest_at` is marked as no longer read. BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the unsubscribe routes — the canonical /public/engagement pair plus the /public/teams path kept permanently because mail is not editable once sent. Code: RunicGateway/website#TBD Co-Authored-By: Claude <noreply@anthropic.com>
197 KiB
Platform Teams & Community Integration — design of record
Status: design, not built. Written 2026-08-17 against the working tree at
websiteorigin/maine0c961c(the module-system cutover),module-uomain97e2fdd,linkmain7b65840,servuo-pluginsmainc045bdd. Nothing below is implemented.Companion to
MODULE_SYSTEM.md(what the module system is),MODULE_API.md(the normative core↔module contract),THEMING_AND_NAV.md(nav registration and its gates),BACKEND_DESIGN.md(API/schema/security conventions) and../link/PROTOCOL_2.md§10.1 (the guild stream this depends on).
The one sentence. 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 — pages, roster, activity, forums, notifications, external integrations — is core's, and Discord is one optional consumer of it rather than the place Teams live.
Game → Module → Team (core) → pages · roster · activity · forums · notifications
→ optional integrations (Discord today; another platform later)
Where the boundary is: Part 10. This document spans a versioned contract, a large body of core-internal code, and a module, and the three appear in the same paragraphs throughout. Part 10 sorts every artifact into one of four classes and is the section to check a specific question against. The short version: almost none of this is contract. The contract is the ten members in §10.2; the ~15 tables are core-internal and a module may never touch them, even though the module is what populates them.
Part 0 — What is actually there today
Five findings changed the design. They are recorded first because three of them contradict the brief this document was written from, and a plan built on the brief's version would have been wrong.
0.1 The guild stream carries counts, not a roster — this is the largest single gap
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeSocial.cs sweeps BaseGuild.List every
GuildSweepSeconds (60s), folds each guild to a signature, and emits guild.update on change:
{"kind":"guild.update","id":1234,"name":"The Silver Hand","abbr":"TSH",
"members":14,"online":3,"alliance":null,"leader":{"serial":"0x42","name":"Aldric","acct":"…","webId":7}}
members and online are integers. There is no member list on the wire, none in the sidecar's
guilds board, and none in module-uo's shard_guilds table (module-uo/server/db/schema.sql:206,
whose columns are members INT / online INT). guild.remove carries only an id. There is a
real-time guild.join (from EventSink.JoinGuild) but no guild.leave — the file's own comment
says a leave "surfaces as the member count dropping in the next guild.update".
There is also exactly one leader: leader_* columns flattened from Guild.Leader.
So getTeamMembers(), getTeamLeaders(), team.member.added, team.member.removed, the roster, the
linked/unlinked split and reconciliation have no data source at all today. Every one of them
requires new emitters in servuo-plugins/, new board storage and a REST projection in link/, ingest
in module-uo/, and a spec change in docs/ — a coordinated four-repo change with a
PROTOCOL_VERSION bump (§9, Phase 1).
Two things make this cheaper than it sounds. ServUO's Guild.Id is a persistent integer that
survives a rename, so the module can distinguish rename-vs-different-guild exactly as Part 2 requires.
And ServUO guilds already carry per-member ranks (RankDefinition.Ranks, Scripts/Misc/Guild.cs:38
— Ronin/Member/Emissary/Warlord/Leader, and any number of members may hold rank 4), so "multiple
leaders from the start" is a GuildRank.Rank >= 4 read, not a modelling problem.
0.2 There is no module→core news hook to generalize
The brief's Part 1.3 assumes "modules already push news items that the site delivers". They do not. Every existing news seam runs core → module:
ctx.postsis read-only —listAll,getById,linkAnnounceJob,markAnnounced.create,updateandremoveare deliberately withheld (MODULE_API.md§2.3): "the CMS is not a module's."registerPostHook({ onSaved, onDeleted })is core telling a module a post changed.registerAnnounceLeg({ dispatch, classify })is core asking a module to deliver a core post somewhere else — that is how module-uo's town crier gets news into the game.
The only module→core content seam that exists is ctx.push.publish(streamId, { ref, ownerUserId }).
So the Team activity feed cannot reuse the news hook; it needs its own ingestion member, modelled
on ctx.push.publish (§4). This is recorded as a correction, not a preference.
0.3 SSE is entirely module-owned; core has none
grep -rn "text/event-stream" across the working tree returns zero hits in website/server/ and
three in module-uo/ (server/utils/shardBroadcast.js is the whole implementation). The
public/admin fan-out, the audience-rung resolution and the field projection all left core with the
module-system cutover.
So a core-owned Team roster cannot subscribe to a core live channel — there isn't one. Roster
online-status is therefore module-projected data delivered through the Team provider (§2.3), and
the live half of it is a module-owned client concern: module-uo already publishes
useShardFeed/shardEvents in its client chunk and already renders live boards. Core's Team page
renders a last-known online count from the sync and declares a team.overview extension slot (§3) the
module fills with anything live. Core does not grow an SSE stack for this.
0.4 The Discord bot is a separate container that cannot load module code
website/docker-compose.yml: the bot service is a distinct prebuilt image with its own DB pool
(bot/src/db.js), no ./modules bind mount, and no published port. The two processes talk over
exactly two shared-secret HTTP channels:
- app → bot,
utils/botInternalClient.js→bot/src/internal/internal.routes.js(/internal/config,/internal/status,/internal/announce,/internal/mod-reverse, and since phase 7/internal/refresh-commands), 4s timeout, never throws, always returns{ ok, status, data, error }. - bot → app,
SITE_INTERNAL_URL=http://app:3001/internal/bot-configon the app's unpublished internal listener (server/src/internalApp.js), with a retry-with-backoff bootstrap so a bot restart self-heals. Phase 7 addedbot/src/site/appInternalClient.jsfor/internal/commandsand/internal/commands/dispatchon that same listener — it derives the base fromSITE_INTERNAL_URL's origin rather than taking a second variable naming the same host.
Slash commands are registered from a static array (bot/src/discord/commands/index.js) and pushed
with REST.put(Routes.applicationGuildCommands(...)) on ready (discordManager.js:25) — a whole-set
PUT, which means deregistration is already free.
A module therefore physically cannot put a handler function in the bot process. The command contract has to cross a process boundary (§6.1), and that is a constraint of the deployment, not a design choice.
0.5 Notification subscriptions have no scope dimension
notification_subscriptions is PRIMARY KEY (user_id, stream_id) — nothing else. The stream catalog
is a static registration validated at boot (registries.js:checkStreamShape, id pattern
<moduleId>.<name>), so "one stream per Team" is not expressible: the catalog is fixed before any
Team exists. pushDispatch.publish fans out to either every subscriber of a stream or one
ownerUserId's devices. There is no "these N users" path.
The good news is that the ntfy layer itself needs nothing: a topic is a per-device UnifiedPush endpoint, not a per-subject channel, and the payload is a content-free tickle. Team scoping is a recipient-set problem inside the website, not a topic problem in the relay (§5).
0.6 What is reusable as-is
| Thing | Where | Used for |
|---|---|---|
| Account link (game ↔ site) | module-uo shard_account_links (account → user_id) |
the first hop of the identity chain (§2.5) |
| External identity (site ↔ Discord) | core user_identities (provider, subject, user_id) |
the last hop; already how appeals matches a player to a mod_actions target |
| Online characters + linked user | module-uo shard_online (serial, name, acct, web_id) |
roster online-status without any new transport |
| Audience rungs + field projection | module-uo/server/utils/shardVisibility.js |
roster field-level gating (§3.3) |
| Audit log | core activity_log + ctx.activity.log |
forum grant/revoke audit (§2.6) |
| HTML sanitizer | core utils/sanitizeHtml.js (wiki, posts) |
forum post bodies — but via a derived, stricter profile, never cleanBody itself (§5.5.3) |
| Rate limiting | ctx.middleware.rateLimit |
forum write paths |
| Push fan-out | utils/pushDispatch.js |
Team notifications (§5) |
| Best-effort bot channel | utils/botInternalClient.js |
the notifications bridge (§6.2) |
| Client extension slots | MODULE_API.md §3.7 |
module content on a core Team page (§3.4) |
| Nav registration + override merge | MODULE_API.md §3.3, THEMING_AND_NAV.md §7 |
the Teams nav entry (§3.5) |
0.7 Decisions taken by the org lead before this was written
- Teams are module-sourced only. Bare core has no Teams and renders no Team UI. There is exactly one writer of the membership projection.
- Slash commands: definition + handler registered in the website, execution dispatched back over internal HTTP. The bot owns every Discord-specific concern.
- Roster on the wire: full member list, but site identity only for linked members. Every member
contributes
serial,name,rank,online;acct/webIdare present only for members whose game account is linked. - Team pages and rosters are public, projected per the existing shard-visibility audience rungs. Forums and the activity feed are members-only.
Part 1 — Scope, and the invariants everything else serves
Six invariants. Each has a test named against it in the phase that introduces it.
- Module unavailability is staleness, never emptiness. No Team subsystem — sync, roster, leaders, online status, integration reconciliation — may apply a destructive result derived from a failed, timed-out or unanswered module call. §2.4 is the mechanism.
- Four authority paths stay four. Game membership, leadership, forum access and external-platform access are separate tables answering separate questions, resolved by separate predicates. No predicate reads another's table.
- Non-contamination. A manual forum grant never writes the membership projection, in either direction, ever. Both facts coexist; neither migrates into the other.
- A Team's name is immutable for the life of its record. A rename is an archive plus a create.
- Core never interprets module vocabulary. Activity
kinds, Team metadata and capability strings are opaque. Core stores, gates and displays; it never branches on content it does not own. - The game never touches the website. Everything crosses the sidecar. Unchanged from
MODULE_API.md§2.7.
Out of scope, explicitly: multi-module namespacing (single active module per deployment —
module_id columns exist so this is later-friendly, and nothing is built to exercise them); Team
hierarchies/alliances; cross-Team messaging; a Matrix implementation (§7 is research only);
platform-only Teams with no game backing (§0.7 decision 1 — the source discriminator is not added
speculatively, since decision 1 was "module-sourced only", and adding it later is one additive column).
Part 2 — Team core
2.1 Persistence
New core tables, all in server/db/schema.sql, all forward-only and idempotent per MODULE_API.md
§2.6's rules (CREATE TABLE IF NOT EXISTS, ALTER TABLE … ADD COLUMN IF NOT EXISTS, no DROP).
Core tables carry no prefix requirement — that rule (§2.6) binds modules, not core.
-- The Team itself. `external_id` is the module's own stable identity for it
-- (module-uo sends the persistent ServUO Guild.Id). `name` is immutable: a
-- rename archives this row and creates a new one (§2.2).
CREATE TABLE IF NOT EXISTS teams (
id INT AUTO_INCREMENT PRIMARY KEY,
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
external_id VARCHAR(191) NOT NULL, -- opaque to core
name VARCHAR(160) NOT NULL,
abbr VARCHAR(32) NULL,
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
status ENUM('active','archived') NOT NULL DEFAULT 'active',
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
-- Public suppression, independent of status. A hidden Team still works
-- completely for its own members; it is absent from public surfaces (§2.8).
hidden TINYINT(1) NOT NULL DEFAULT 0,
hidden_reason ENUM('reserved_name','staff') NULL,
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
display_name_override VARCHAR(160) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL,
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
-- A generated column is how "unique among ACTIVE rows only" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
-- any number of archived rows may share an external_id.
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
UNIQUE KEY uq_teams_active (module_id, active_key),
UNIQUE KEY uq_teams_active_slug (active_slug),
INDEX idx_teams_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The membership PROJECTION. Module-authoritative; core only mirrors it.
-- Rows are soft-departed rather than deleted so history and rejoin detection
-- survive, and so the activity feed can still name a departed member.
CREATE TABLE IF NOT EXISTS team_members (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
display_name VARCHAR(160) NULL, -- in-game name
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
is_leader TINYINT(1) NOT NULL DEFAULT 0,
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
online TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('active','departed') NOT NULL DEFAULT 'active',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
departed_at DATETIME NULL,
PRIMARY KEY (team_id, member_key),
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_team_members_user (user_id),
INDEX idx_team_members_status (team_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Freshness of the module's answer. One row per module. THE table invariant 1
-- is enforced against.
CREATE TABLE IF NOT EXISTS team_sync_state (
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
last_attempt_at DATETIME NULL,
last_success_at DATETIME NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
-- answer is remembered here and applied only if the NEXT one agrees.
pending_empty_since DATETIME NULL,
INDEX idx_team_sync_success (last_success_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
teams.slug exists because the Team page URL must be stable and readable, and the game's name is not
URL-safe. It is derived once at create (slugify + numeric suffix on collision) and, like name, never
changes for the life of the row.
2.2 Identity and the rename rule
Core's key is (module_id, external_id, name) taken together, not external_id alone.
- New
external_id→ create a Team. - Known
external_id, samename→ update in place (abbr,meta, counts, roster). - Known
external_id, differentname→ archive the existing row (status='archived',archived_reason='renamed') and create a new one. The old Team keeps its forum, its activity history, its grants and its integration record; all become read-only. external_idabsent from an authoritative full list → archive (archived_reason='disbanded'), subject to §2.4's guard.
This puts the whole of "is this a rename or a different guild?" inside the module: if a game has no
persistent guild id, its module can synthesise external_id from whatever is stable, or fold the
name into it so every rename is a fresh id. Core sees only "an id appeared / an id's name changed / an
id is gone".
An archived Team is reachable at its old slug (read-only, noindex), so a Discord message or a
bookmark from before the rename still lands somewhere that explains itself rather than 404ing. Core
renders a banner linking to the successor when one exists — recorded via a nullable
teams.succeeded_by INT NULL written at archive time.
2.3 The module-facing interface
A new registration, staged and committed like every other (MODULE_API.md §2.4). One provider per
deployment; a second registration is a collision and is rejected.
api.registerTeamProvider({
getTeams, // () => Promise<{ ok, complete, teams }>
getTeamMembers, // (externalId) => Promise<{ ok, complete, members }>
getTeamLeaders, // (externalId) => Promise<{ ok, leaders }> // leaders = [memberKey]
})
Every method returns an envelope, never a bare array. This is the mechanism for invariant 1 and it
is why the signature does not look like the brief's getTeams():
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr, meta } ] }
// the module knows it cannot answer — sidecar down, cache cold, boot not finished
{ ok: false, reason: 'sidecar unreachable' }
A rejected promise, a timeout (core budget: 10s), a non-object, or a missing ok is treated exactly
as { ok: false }. There is no shape a failure can take that core reads as "zero teams". A bare
array would have had one — [] — and that is the whole argument for the envelope.
complete: false means "this is a valid but partial answer": core applies additions and updates and
performs no removals. It exists for a module that can enumerate cheaply but not exhaustively.
A member:
{ memberKey: '0x40012ab3', // stable, module-owned
displayName: 'Aldric',
rankLabel: 'Warlord', // opaque
leader: false,
online: true,
userId: 7 | null } // resolved BY THE MODULE — it owns the link table
userId is resolved module-side deliberately. shard_account_links is module-owned
(module-uo/server/db/schema.sql:144), and a core that resolved it would be core reading a module's
table by name. The cost is that a newly linked account does not appear as linked until the next
sync — closed by the module calling ctx.teams.reconcile() immediately after a successful link, which
it already has the hook for.
Alongside the pull interface, three ctx members for push:
ctx.teams.publish(event) // team.created | team.disbanded | team.member.added | team.member.removed
// + team.leader.added | team.leader.removed
ctx.teams.reconcile({ reason }) // request an immediate reconciliation; debounced, never awaited by the caller
ctx.teams.activity.push(items) // §4
Six event kinds, not the brief's four: leadership is its own authority path (§2.5), so a leadership change must be expressible without pretending someone joined or left.
Events are an optimisation, never the source of truth. They make the common case immediate;
reconciliation is what makes it correct. An event for an unknown externalId schedules a
reconciliation rather than inventing a Team, because a Team created from a delta has no name, no
roster and no leaders.
2.4 Reconciliation, and the stale/empty rule
When it runs:
| Trigger | Why |
|---|---|
onBoot, after every module started |
the website may have been down across a whole guild war |
Poll, default 900s (teams_reconcile_interval_s setting) |
the backstop for a missed event |
ctx.teams.reconcile() |
the module knows something core cannot — a sidecar reconnect, a fresh account link |
| Admin → Teams → Resync | the operator's escape hatch |
| An event naming an unknown Team | a delta arrived before its subject existed |
Runs are serialised per module (an in-process lock) and debounced to at most one per 30s, so a sidecar flapping cannot turn into a reconciliation storm.
The algorithm, and the four places it refuses to act:
- Call
getTeams(). Notok→ writeteam_sync_state(increment failures, record the error), log atwarn, return without touching a single row. Backoff is exponential onconsecutive_failures, capped at the poll interval. okbut the list is empty while core holds ≥1 active Team → do not apply. Stamppending_empty_sinceand return. Apply only if the next authoritative answer, at least one full interval later, is also empty. Rationale: a module that answersok:truewith an empty list during a sidecar cold start is the one failure indistinguishable from a real wipe, and "every Team on the shard disbanded simultaneously" costs one interval of delay to confirm. Any non-empty answer clears the quarantine.- For each Team in the answer, call
getTeamMembers(). Notokfor that Team → leave that Team's roster entirely untouched, mark it stale, continue with the others. One Team's unanswerable roster is not the other Teams' problem, and it is certainly not an empty roster. okbut zero members for a Team that currently has members → same two-strikes quarantine as (2), per Team.
Only after those gates: upsert Teams, upsert members (last_seen_at bumped), mark unseen members
departed, archive unseen Teams, recompute the three counts, and write last_success_at.
Staleness is surfaced, not silent. GET /api/v1/public/teams and every Team page carry
{ stale: bool, lastSyncAt }; the UI renders "roster last confirmed 14 minutes ago" once past a
threshold (2× the poll interval). Admin → Teams shows team_sync_state verbatim, including the last
error. A stale sync also suspends every integration reconciliation (§6.3) — a voice channel is
never created or destroyed on data core does not trust.
2.5 The four authority paths
The single most important structural rule in this document: these are four tables answering four questions, and no resolver reads another path's table.
| # | Question | Authoritative source | Table | Who can change it |
|---|---|---|---|---|
| 1 | Is this account a member of the Team? | module | team_members |
the sync, and only the sync |
| 2 | Does this account lead the Team? | module | team_members.is_leader |
the sync; plus staff override (§2.5.1) |
| 3 | May this account use the Team forum? | core | team_forum_grants OR path 1 |
staff, and leaders (grant only ordinary access) |
| 4 | May this account get external-platform access? | core, derived | none of its own | nobody directly — it is computed |
Path 2 — leadership. The sync writes is_leader from getTeamLeaders(). Multiple leaders are the
normal case. A leader can never promote or demote another leader: there is no endpoint that writes
is_leader, at all, other than the sync and the staff override below. This is enforced structurally —
the leader-facing controller has no code path to that column — and asserted by a test named for the
rule.
2.5.1 Staff override on leadership
Staff may set a team_leader_overrides (team_id, member_key, effect ENUM('grant','deny'), actor_user_id, reason, created_at) row, applied on top of the synced value at read time. The projection is never
mutated (invariant 3 generalised): the sync keeps writing what the game says, and the override keeps
saying what staff decided. Overrides are listed on the Team's admin page with who set them and why,
and they survive a resync — which is the entire point, since a sync that clobbered a staff decision
every 15 minutes would be useless. This is the "staff can intervene where the platform already permits
staff intervention" clause, made concrete.
Path 3 — forum access.
-- Append-only grant/revoke ledger AND current state. An active grant is one with
-- revoked_at IS NULL; the generated column is again how "one active grant per
-- (team,user)" is expressed without a partial index.
CREATE TABLE IF NOT EXISTS team_forum_grants (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NOT NULL,
granted_by INT NULL, -- NULL only for a system grant
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255) NULL,
revoked_by INT NULL,
revoked_at DATETIME NULL,
revoke_reason VARCHAR(255) NULL,
active_user INT AS (IF(revoked_at IS NULL, user_id, NULL)) STORED,
UNIQUE KEY uq_team_forum_grant_active (team_id, active_user),
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_tfg_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The resolver, which is the whole of path 3:
// server/src/model/teams/teamAccess.model.js
async function forumAccess(teamId, userId) {
const grant = await grants.activeFor(teamId, userId) // team_forum_grants
const member = await members.activeByUser(teamId, userId) // team_members
return {
allowed: Boolean(grant) || Boolean(member),
viaMembership: Boolean(member),
viaGrant: Boolean(grant), // retained even when viaMembership is also true
isLeader: Boolean(member && member.is_leader) || staffOverrideGrant,
}
}
Two reads, OR'd. viaGrant is reported even when membership also holds — that is the "both facts
coexist; the UI presents membership as the current reason while the grant is kept as audit history"
requirement, and it falls out of not collapsing the two booleans into one.
- A grant may name any Runic Gateway account, including one with no linked game identity of any kind. That is the point: letting an unlinked guildmate into the forum must not be a staff ticket.
- Granting never writes
team_members. Revoking never writesteam_members. A test asserts a byte-identicalteam_membersrow set across a grant/revoke cycle. has forum accessis never read asis a Team member. A test asserts that a granted, unlinked user is absent fromGET /teams/:slug/membersand from every membership count.
Who may grant. Staff (admin, moderator) always. A leader may grant and revoke ordinary
forum access on their own Team, and cannot grant leadership or touch membership — there is no
parameter that would let them try. A leader cannot revoke a staff-issued grant
(granted_by is staff), which stops a leader undoing a moderation decision.
Leader grants are capped and rate-limited. A per-Team ceiling on active grants
(teams_max_grants_per_team, default 50) plus core's rateLimit middleware on the grant route.
Without it a leader can admit unlimited arbitrary accounts to a private space on the operator's site,
which is a quiet way to turn a Team forum into open hosting. Staff are exempt from the cap and see a
warning when they cross it.
Path 4 — external access. Computed, with no table of its own, and deliberately blind to path 3:
async function externalEligible(teamId, userId, platform) {
const member = await members.activeByUser(teamId, userId) // path 1 ONLY
if (!member || member.user_id == null) return false // must be a LINKED game member
return identities.has(userId, platform) // core user_identities
}
A test asserts externalEligible returns false for a user with an active forum grant and no
membership. The reason, stated so nobody "fixes" it later: an integration cannot verify that an
unlinked, forum-granted account corresponds to a real game member, so it must not hand that account a
privilege on a platform where impersonation has consequences.
2.6 The identity-binding chain
Four hops, three of them stored in different places, and the design's most common source of confusion — "this account is linked" answers only one of them.
Runic Gateway account users.id
│ hop 1: linked GAME identity module-owned: shard_account_links(account → user_id)
▼
game account / character team_members.member_key, team_members.user_id
│ hop 2: Team membership core: team_members (the sync's projection)
▼
Team member teams.id
│ hop 3: linked EXTERNAL identity core: user_identities(provider='discord', subject)
▼
Discord user id the thing the bot can actually act on
│ hop 4: the integration's own grant
▼
channel permission overwrite / role
Hops 1 and 3 are independent opt-ins by the same person. Every one of the four failure modes is a real state with a distinct surface:
| Broken hop | What it looks like | What the UI says |
|---|---|---|
| 1 | in the guild, invisible to the site | roster row, greyed, "not linked" |
| 2 | linked account, not in this Team | not on the roster at all |
| 3 | linked Team member, no Discord | roster row normal; voice access absent, "link Discord to join voice" |
| 4 | everything linked, bot could not act | Admin → Teams shows the integration error |
2.7 Audit
Every forum grant, forum revoke, leadership override, staff archive and manual resync writes a core
activity_log row through the existing activity.log({ req, action, detail }) — the same log staff
already read, so a Team action is not in a second place nobody checks:
team.forum.grant detail: 'Leader Bob (#12) granted forum access to Alice (#88) on team "The Silver Hand" (#3): "recruit, not linked yet"'
team.forum.revoke …
team.leader.override …
team_forum_grants remains the structured record (it is what the resolver reads);
activity_log is the human-readable trail. Both, not one — the ledger cannot be paged through by an
admin looking for "what did this moderator do last week", and the activity log cannot answer "does
Alice have access right now".
2.8 Reserved-name screening — the one place untrusted game data becomes a public page
The threat. A Team's name is written by a player, inside the game, with no review, and this design then turns it into a public page, a URL, a nav-reachable entity, a Discord message and eventually a voice-channel name. Someone naming their guild "Admin", "Moderator" or "UOMysticmoon Staff" gets an official-looking page on the operator's own site, for free, by typing a name into a guild stone. That is an impersonation vector, and it is the only place in this design where unsanitized game data acquires platform authority — which is exactly why the approval gate in §2.9 is scoped to it rather than to staff actions generally.
2.8.1 What is reserved
A core utility, utils/reservedNames.js, resolved at check time (never baked in, since the brand is
runtime configuration):
| Source | Terms |
|---|---|
| Role names | the users.role enum — admin, editor, moderator, player — plus staff, administrator, mod, owner, gm |
| The deployment's brand | settings.site_title, BRAND_NAME, BRAND_SHORT_NAME — i.e. whatever settings.getInstanceName() resolves to, plus brand.shortName |
| The project | Runic Gateway — impersonating the software project is as much a problem as impersonating the operator |
| Operator additions | a teams_reserved_terms setting, comma-separated, for anything a particular community needs |
The project's name has two legitimate presentations, and both must match. Runic Gateway (two words) is the correct form; RunicGateway is accepted only where the name has to condense to a single token — the Gitea org, a package name, a URL segment. A reserved term is therefore stored in its correct two-word form and matched against both, which §2.8.2's whitespace-insensitive comparison gives for free. Listing the condensed form as a separate term would be a second thing to keep in sync, and it would still miss
runic-gatewayandRunic_Gateway.The same applies to a deployment's own brand:
BRAND_NAMEis free text and an operator may well have set a spaced name whose condensed form is what a would-be impersonator types.
Not filter_words. That table exists but is bot-owned (its own pool, bot/src/model/,
never read by the website — MODERATION_APPEALS.md §2 is explicit that the two sides share no live
FK) and it is a profanity filter, which is a different question with a different answer. Reusing it
would cross an ownership boundary to get the wrong list.
2.8.2 How it matches
Whole words, after normalisation — not substrings. Core already has the precedent and the scar
tissue for this: scripts/checkModuleIdentifiers.js tokenises on camelCase humps and on
-/_/.// and compares word by word, precisely so defaultImage does not match "ultIma"
(MODULE_API.md §5.2). The same discipline applies here, for the same reason: a substring match
flags "Badminton" for containing "admin", and a check that cries wolf is a check people switch
off.
Normalisation: case-fold, strip punctuation and repeated characters, collapse whitespace. Deliberately
no leet-speak folding in v1 (4dm1n) — it multiplies false positives, and the consequence of a
miss is a hidden-by-a-human Team rather than a breach.
A multi-word term is additionally compared with whitespace removed on both sides, so a term stored
as Runic Gateway matches RunicGateway, runic-gateway, Runic_Gateway and RUNIC GATEWAY
alike. Without this the whole-word rule fails on exactly the case that matters: RunicGateway is a
single word and would never match a two-word term, so the condensed form — the one an impersonator
would reach for, because it is what the Gitea org and every URL already use — would sail straight
through.
That widening applies only to terms containing whitespace, which keeps it away from the
single-word terms where whole-word matching is doing the false-positive work: admin is still
compared as a word and still does not fire on "Badminton". A two-word term is specific enough that
running the letters together cannot collide with ordinary vocabulary.
2.8.3 What a match does: hide, never reject
A Team whose name trips the list is created normally and then auto-hidden: hidden = 1,
hidden_reason = 'reserved_name', hidden_term recording which term matched.
- Hidden means absent from every public surface — the
/teamsindex, search, the roster, activity, the Discord bridge, and any voice provisioning. It is not archived and not deleted. - The Team works completely for its own members: its forum, its grants and its notifications are untouched. The people in it are not being punished for a name their leader chose.
- It lands in a review queue on Admin → Teams, with the matched term shown.
Hide rather than reject, and this is the point. Core cannot refuse a name — the guild already exists in the game and core is a mirror, not an authority over it. And because the failure mode of an over-eager match is "a legitimate guild is invisible until a human looks", false positives are cheap and recoverable while false negatives are not. That asymmetry is what lets the matcher be conservative without being clever.
The staff override to allow. Staff clear the hide (hidden = 0), which is recorded with who,
when and why. A display_name_override may be set instead of, or alongside, un-hiding — that changes
what is rendered everywhere (page, nav, Discord channel name) while the identity name stays
frozen, so §2.2's immutability rule is untouched. Identity and display are different things; only
identity is immutable.
Re-screening runs on every sync, but an explicit staff decision is sticky: once staff have allowed a name, a later sweep does not re-hide it. Otherwise the override would be undone every 15 minutes.
The same exposure already exists elsewhere, and this does not close it. Character names reach public pages today through
shard_onlineand the existing guilds board — a character called "Admin" is already renderable.utils/reservedNames.jsis written as a general core utility so module-uo can adopt it for those surfaces, but doing so is not part of this workstream and is noted here so the gap is recorded rather than implied to be fixed.
2.9 The approval gate on game-sourced overrides
Scope, decided: approval applies only to actions that release untrusted game-sourced data onto public surfaces — not to staff actions generally. Concretely, three actions:
| Action | Why it is gated |
|---|---|
Clearing a reserved_name hide |
publishes a name that tripped the impersonation list |
Setting a display_name_override |
substitutes free text into the same public surfaces |
Un-hiding a staff-hidden Team |
reverses a deliberate suppression |
Everything else staff can do — ordinary forum grants, leadership overrides, archive, forum moderation — applies immediately and is audited, as before. Gating them would be a general staff-approval workflow, which is a different (and much larger) idea; §9 answer 6 records why that was not what was asked for.
The rule: a moderator initiating one of the three creates a pending request that takes effect
only when an admin approves it. An admin initiating one applies it immediately, logged. This
mirrors the appeals queue's existing claim → resolve shape rather than inventing a second workflow
vocabulary, and it cannot deadlock a single-admin deployment — which matters, because users.role
defaults to admin and npm run seed creates exactly one, so most deployments have precisely one
admin and a four-eyes rule would wedge them.
CREATE TABLE IF NOT EXISTS team_moderation_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
payload JSON NULL, -- e.g. { "displayName": "…" }
reason VARCHAR(255) NULL,
requested_by INT NULL,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
decided_by INT NULL,
decided_at DATETIME NULL,
decision_note VARCHAR(255) NULL,
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Rows are kept after a decision — the record of "a moderator asked to publish this name and an admin
refused" is the part worth having. Every transition also writes activity_log (§2.7).
The gate is server-side and role-checked per request, not a UI affordance: core's admin routes are already re-validated against the database on every request (a demoted user loses access at once), so "is this caller an admin" is answered live rather than from a token claim.
Built generically enough to extend, not generalised speculatively. action + payload means a
fourth gated action is an enum value, not a schema change — but no other action is gated today, and
none should be added without the same question being asked: does this publish untrusted game data?
2.10 Account deletion
Settled here rather than inherited from whatever the foreign keys happen to say, because retrofitting it is painful and two of the obvious defaults are wrong:
| Table | On user delete | Why |
|---|---|---|
team_members.user_id |
SET NULL |
the member still exists in the game; only the site link goes |
team_forum_posts.author_user_id |
SET NULL |
a thread others replied to must not lose its posts |
team_forum_grants.user_id |
SET NULL + retained username snapshot |
CASCADE would destroy the audit trail of who granted whom — exactly what an audit exists to survive. A granted_username / revoked_username snapshot column keeps the record readable. |
team_forum_grants.granted_by / revoked_by |
SET NULL + snapshot |
same reason, for the actor |
team_activity.actor_user_id |
SET NULL |
the event happened; the feed keeps the module-supplied summary |
team_forum_uploads.user_id |
SET NULL, and mark deleted_at |
an orphaned file with no owner and no deletion trigger is the worst outcome; deleting the account removes the files |
team_moderation_requests.requested_by / decided_by |
SET NULL + snapshot |
as above |
team_notification_prefs |
CASCADE |
a preference with no user is meaningless |
The rule the table encodes: content and audit survive; preferences and links do not. The one place it bites is uploads, where "forget me" has to mean the bytes go too, not just the row.
2.11 API surface
GET /api/v1/public/teams list (active), paged, {stale,lastSyncAt}
GET /api/v1/public/teams/:slug overview + counts
GET /api/v1/public/teams/:slug/members roster, row-projected per audience rung (§3.3)
GET /api/v1/public/teams/:slug/activity the feed, paged, filtered to what the caller may
see (§4.3) — added in phase 3; a session is
optional on this route and on /members
GET /api/v1/player/teams the caller's Teams (membership + grants), with the
reason for each: 'membership' | 'grant' | both
GET /api/v1/player/teams/:slug/access the caller's own resolved access on one Team
GET /api/v1/admin/teams incl. archived, sync state, per-Team integration state
POST /api/v1/admin/teams/resync manual reconciliation
POST /api/v1/admin/teams/:id/archive staff archive (archived_reason='staff')
GET /api/v1/admin/teams/:id/grants the full grant ledger, incl. revoked
POST /api/v1/admin/teams/:id/leader-override { memberKey, effect, reason }
DELETE /api/v1/admin/teams/:id/leader-override/:memberKey
GET /api/v1/admin/teams/review the reserved-name review queue (§2.8.3)
POST /api/v1/admin/teams/:id/unhide admin: applies · moderator: creates a pending request
POST /api/v1/admin/teams/:id/display-name same gate; { displayName, reason }
POST /api/v1/admin/teams/:id/hide staff hide — NOT gated (suppression is always safe)
GET /api/v1/admin/teams/requests the §2.9 approval queue
POST /api/v1/admin/teams/requests/:id/decide admin only; { status: 'approved'|'rejected', note? }
POST /api/v1/player/teams/:slug/grants leader-grantable forum access { userId | username, reason }
DELETE /api/v1/player/teams/:slug/grants/:userId leader revoke (not of a staff-issued grant)
Leader-exercised actions live under /player rather than /admin deliberately: a leader is a player,
the /admin tier gate is requireRole('admin','editor','moderator')
(MODULE_API.md §2.4), and putting a leader endpoint behind it would mean widening that gate. The
leader check is a per-route gate on top of the /player tier's requireAuth.
Process contract for every route above, per CLAUDE.md: #swagger.* annotations →
npm run swagger, npm run routes:manifest -- --check zero-line diff (the additions are core's, so
they land in core's committed manifest), and a matching BACKEND_DESIGN.md edit.
Part 3 — Team pages and roster
3.1 Routes and shell
Superseded 2026-08-17 (phase 3, org lead). The four public/player rows below are NOT core's. Teams is a contract primitive and core does not own the vocabulary, so the module that owns the word owns the page:
module-uorenders these under/uo/guilds. Only the two/admin/teamsrows are core's. See the phase 3 amendment in Part 12.
Core client routes, not module ones:
| Path | Page |
|---|---|
/teams |
index — searchable list, name/abbr/member/online counts |
/teams/:slug |
overview — counts, leaders, recent activity (§4), forum entry point (§5) |
/teams/:slug/roster |
full roster |
/teams/:slug/forum/* |
§5 |
/player/teams |
the caller's Teams and what each grants them |
/admin/teams, /admin/teams/:id |
sync state, grants ledger, overrides, integrations |
Public pages use PublicLayout shell="wide" for the roster and "mid" elsewhere — the shell prop
added in MODULE_API_VERSION 1.5.0 exists precisely so a page renders inside the site's column, and
core's own pages are the ones that most often forget it.
3.2 Roster
Rows come from team_members (status='active'), sorted leaders first then by display_name. Each
row carries a link state — this is the surfaced divergence Part 2 asks for, and it is a first-class
column rather than an absence:
| State | Condition | Rendered |
|---|---|---|
linked |
user_id IS NOT NULL |
name links to the public profile |
unlinked |
user_id IS NULL |
name in muted text + an "not linked" chip |
granted |
not a member; active forum grant | listed separately, under "Forum guests", never counted as members |
The header states it plainly — "37 members · 21 linked · 4 forum guests" — so the 37/21 gap reads as
information rather than as a bug. linked_count is denormalised on teams so the index page does not
need a join per row.
3.3 Online status, without new transport
team_members.online is written by the sync from the module's online field, which module-uo
computes from shard_online — data it already holds. Refresh cadence is the reconcile interval, so
the stored value is coarse.
Live refinement is the module's, through the team.overview extension slot (§3.4): module-uo's client
chunk already has useShardFeed and a live presence.online view, and it can render a live "online
now" strip on the Team page without core acquiring an SSE stack for it (§0.3). Core's number is the
durable floor; the module's is live. If the module is absent or its slot throws, the page shows the
stored value and the error boundary contains the rest (MODULE_API.md §3.7).
Field projection: the roster response passes through the module's audience-rung projection before it
leaves the server. acct and webId-equivalents never reach a caller below the configured rung —
this is the same boundary shardBroadcast enforces on the live feed, applied to a core response, and
it is why the roster endpoint asks the module to project rather than serialising team_members
directly. Concretely: core hands the module the rows and the viewer, the module returns the rows it
permits. A module that declines (unavailable) yields the public projection, not the full one — fail
closed.
3.4 Extension slots
Two new core-declared slots (MODULE_API.md §3.7 — core declares, one module fills, errors contained,
named for a place and never for a meaning):
| Slot | Rendered in | Props |
|---|---|---|
team.overview |
the Team overview page, below the counts | { teamId, externalId, moduleId } |
team.member.row |
each roster row, trailing cell | { displayName, isLeader, linked } |
Superseded 2026-08-17 (phase 3, org lead). Both slots are gone, and the DIRECTION is what changed. They assumed core rendered the Team page; core renders no Team page. The replacement is
registry.declareModuleSlot(id, name, { core })— a module declares a place on its own page, namespaced under its own id, naming which of core's contributions goes there, and core offers it:
Slot Declared by Rendered in Filled by core with Props uo.guild.detailmodule-uoits guild detail page team.activity— the Team activity feed (§4.3){ externalId, moduleId }uo.guild.forummodule-uothe same page, below the feed team.forum— the Team forum (Part 5), added in phase 4{ externalId, moduleId }Core's contributions are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk, so when core offers one, no module-declared slot exists yet. A contribution nothing asks for is a no-op, not an error — the mirror of an unfilled slot rendering nothing. Core names the contribution and never the slot (amended phase 11, inside 1.6.0: as first built it filled the literal names above, which reached
module-uoand no other game).Slotbecomes the ninth member of the shared UI kit so a module renders the place with core's own error boundary, which matters here because the thing being contained is CORE's content failing inside the MODULE's page.The props are the module's own vocabulary.
memberKey/userIdare not among them and could not be: §3.2 withholds both from every public roster, and a client slot only receives what the browser was already sent.
3.5 Nav
Superseded 2026-08-17 (phase 3, org lead). None of the three entries below is registered, and the
teamsfeature flag is not either. Core publishes no Team nav row because a core row would name a surface core does not own, sitting beside the module's own row for the same thing in a different word./admin/teams's sidebar entry, which landed in phase 2 and is an operator view of the primitive, is unaffected and stays.
One coded public header entry, { label: 'Teams', to: '/teams' }, plus { label: 'My Teams', to: '/player/teams' } in the player portal and { label: 'Teams', to: '/admin/teams', group: 'Community' } in the admin sidebar. All three flow through the existing registered-defaults → admin
overrides → role/feature filtering pipeline unchanged (THEMING_AND_NAV.md §7); the override layer can
relabel, reorder, re-section and hide them and — structurally — cannot change their to or their
gates.
Gating on "is a Team provider registered". With no module installed there are no Teams, so the
entry would lead to a permanently empty page. Rather than a new mechanism, core registers a feature
flag (teams) against its own core feature provider — the seam useShardFlags already uses
(MODULE_API.md §3.3) — resolved from GET /api/v1/public/teams's enabled field. Per that section's
fail-open rule, an unknown answer shows the link; the page itself is the gate.
Part 4 — Team activity feed
4.1 The seam
Not the news hook — see §0.2, which is why. A new ctx member, modelled on ctx.push.publish:
await ctx.teams.activity.push([{
externalId: '1234', // the module's Team id; core maps to team_id
kind: 'uo.champion.completed', // namespaced <moduleId>.<name>, opaque to core
summary: 'Completed Champion Neira', // already-rendered text; core never composes one
occurredAt: 1755400000000, // epoch ms
visibility: 'public', // 'public' | 'members' — default 'members'
actorMemberKey: '0x40012ab3', // optional
payload: { … }, // optional, opaque, rendered only by the module's slot
dedupeKey: 'champ:0x77:1755400000000', // optional; makes replay idempotent
}])
summary is module-rendered because core cannot compose "gained 15,000 gold" for a game whose
vocabulary it does not know, and a core that templated it would have acquired exactly the semantics
the module system exists to remove.
visibility defaults to 'members' — fail closed. Core enforces it; the module chooses it. This is
the same shape as the module owning the public-safety filter for push streams
(MODULE_API.md §2.4: "the kinds, the streams and the filter are then one file that moves together").
4.2 Storage
CREATE TABLE IF NOT EXISTS team_activity (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
source VARCHAR(32) NOT NULL, -- 'core' or a module id
kind VARCHAR(64) NOT NULL,
summary VARCHAR(255) NOT NULL,
visibility ENUM('public','members') NOT NULL DEFAULT 'members',
actor_member_key VARCHAR(191) NULL,
actor_user_id INT NULL,
payload JSON NULL,
occurred_at DATETIME NOT NULL,
dedupe_key CHAR(40) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key),
INDEX idx_team_activity_feed (team_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE on dedupe_key — the same idempotence trick shard_events already uses — so a
sidecar reconnect backfill never double-posts.
Core writes its own items through the same table with source='core': core.member.joined,
core.member.left, core.leader.changed, core.forum.thread, core.team.renamed. So the feed is
never empty on a module that pushes nothing, and the rendering path is exercised by core's own content
from day one.
Retention is a setting (team_activity_retain_days, default 90) plus a per-Team row cap (default
2000), pruned by a nightly job in the same scheduler the announce worker uses. Unbounded growth on a
per-Team feed fed by a game loop is the obvious failure and it is cheaper to bound it now.
4.3 Rendering
A timestamped list of summary strings, grouped by day, filtered by the viewer's resolved access
(public items to anyone who may see the Team page; members items to members and forum-granted
users). Core renders text; the team.overview slot is where a module renders anything richer from
payload.
Part 5 — Team forums
5.1 Recommendation: split into two phases
Yes, split. A full threaded forum is threads + posts + editing + soft-delete + pinning + locking + moderation + notification + permission + audit + admin UI + player UI + OpenAPI + manifest, and it is the single largest piece of work in this document. But the split should not be "announcements-only then generalise", because an announcement is a degenerate thread and building it as its own thing then replacing it wastes the work.
The split that does not waste anything is by layer, not by feature:
- 5a — access + announcements + the operator's controls.
team_forum_grants(§2.5), the four-path resolver and its tests, the grant/revoke flow with audit, the leader/staff UI for it, a single announcements stream per Team (threads oftype='announcement', leader-authored, replies disabled), and the two admin settings with their enforcement (§5.5). Ships the entire permission model — which everything else in this document depends on — behind a small, low-risk surface. - 5b — discussion + moderation.
type='discussion'threads with replies, editing, pinning, locking, hiding, and the moderation ledger. Additive: thetypecolumn and the full thread/post schema land in 5a, so 5b enables paths rather than migrating data.
5.2 Schema (all of it lands in 5a)
CREATE TABLE IF NOT EXISTS team_forum_threads (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion',
title VARCHAR(200) NOT NULL,
created_by INT NULL, -- SET NULL on user delete; the post body survives
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_post_at DATETIME NULL,
post_count INT NOT NULL DEFAULT 0,
pinned TINYINT(1) NOT NULL DEFAULT 0,
locked TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/sanitizeHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,
edited_by INT NULL,
status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible',
CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE,
CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfp_thread (thread_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Append-only. Never updated, never deleted.
CREATE TABLE IF NOT EXISTS team_forum_moderation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
target_type ENUM('thread','post') NOT NULL,
target_id BIGINT NOT NULL,
action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL,
actor_user_id INT NULL,
actor_role ENUM('leader','staff') NOT NULL, -- WHICH authority was exercised
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
INDEX idx_tfm_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Bodies are sanitised on write with core's existing utils/sanitizeHtml.js (already the wiki's and
the CMS's sanitiser), stored sanitised, and served without re-sanitising — the same contract those two
already follow. Writes go through ctx.middleware.rateLimit-equivalent core middleware.
ON DELETE SET NULL on authors rather than CASCADE: deleting a user must not silently blow holes in
a thread other people replied to. The post renders as "[deleted account]".
5.3 Moderation stays separate from the site's moderation system
Deliberately, and it is worth writing down because merging them looks tidy and is wrong.
The existing mod_actions / appeals pair is Discord-sanction-shaped: mod_actions is a
bot-owned table (bot/src/model/*, its own pool; the website never writes it), keyed on a Discord
user id, and appeals exists so a player can contest a staff sanction and have it reversed in
Discord (MODERATION_APPEALS.md §2, §6). Routing a guild leader locking a thread into that pipeline
would make a leader's ordinary housekeeping an appealable Discord sanction with a reversal path into
the bot. That is not what it is.
So: team_forum_moderation is its own ledger, and the two are cross-referenced, not merged. Every
staff-exercised forum moderation additionally writes an activity_log row (§2.7), so the site's
existing staff-accountability trail sees it. A leader-exercised one writes only the forum ledger,
visible to the Team and to staff on the admin Team page. If a forum post warrants a site-wide sanction,
that is a separate, existing action a staffer takes against the account.
5.4 API
GET /api/v1/player/teams/:slug/forum/threads
POST /api/v1/player/teams/:slug/forum/threads 5a: announcement (leader); 5b: discussion (any member)
GET /api/v1/player/teams/:slug/forum/threads/:id
POST /api/v1/player/teams/:slug/forum/threads/:id/posts 5b
PATCH /api/v1/player/teams/:slug/forum/posts/:id 5b — author, within an edit window; staff any time
POST /api/v1/player/teams/:slug/forum/threads/:id/moderate { action, reason } — leader or staff
GET /api/v1/admin/teams/:id/forum/moderation the ledger
POST /api/v1/player/teams/:slug/forum/uploads multipart; 404 unless teams_forum_images='uploads'
DELETE /api/v1/player/teams/:slug/forum/uploads/:id uploader (within the edit window) or staff
GET /api/v1/admin/teams/forum/uploads attribution view: who uploaded what, when, how much
The forum settings themselves ride the existing admin settings endpoint rather than getting one of
their own — they are ordinary settings keys. The only special handling is server-side validation of
the acknowledgement (§5.5.5) when teams_forum_images is set to uploads.
Every forum route above answers 404 while teams_forums_enabled is off, and the upload routes
answer 404 in any image mode but uploads — the same guard, applied at two levels, for the same
reason (§5.5.1).
Amended 2026-08-18 (phase 4). §3.1's
/teams/:slug/forum/*core page is gone with the rest of them. The routes below are unchanged — every one is/playeror/admin— but the participant surface is core's fill of the module-declareduo.guild.forumslot (§3.4), so a reader is onmodule-uo's guild page throughout. Two routes were added that this table did not have:GET /api/v1/player/teams/:slug/grants(a leader has to SEE the guests before managing them) andGET /api/v1/admin/teams/forum/settings, which serves the one piece of forum state that is not a public settings key — whether the uploads acknowledgement has been given, by whom, and whether the notice has been reworded since (§5.5.6 keeps that key unpublished).The grant routes deliberately answer while the forum is switched OFF, which no line below says: a toggle-off revokes no grant and the rows stay authoritative (§5.5.1), so the access list has to stay manageable during one. What the switch guards is the forum's CONTENT.
Amended 2026-08-18 (phase 5). The 5b routes are as tabled, with three notes the table does not carry.
POST /forum/threadssplits its authority BY TYPE rather than widening the leader gate. Anannouncementstays leader-authored; adiscussionmay be opened by any forum participant — including a granted non-member with no game identity, which is path 3 doing its job.typedefaults toannouncement, so a phase-4 client keeps meaning what it meant; defaulting the other way would silently turn its announcements into discussions. The list response reports the split as two booleans,canPost(may open a discussion) andcanAnnounce(leader), because a client reading one boolean would have to guess which right it described.Post-level moderation is its own route,
POST /forum/posts/:id/moderate, rather than the thread route with a target kind:pinandlockdescribe a thread's place in a list and its openness to replies, neither of which a post has. The route's validator deliberately accepts all eight actions so the model can answerpinwith "pin applies to a thread, not to a post" — restricting it to the four a post takes turns a nameable mistake into a generic validation error, which is what the live rig found.Three refusal codes on a reply, chosen to be distinguishable. 404 for a thread that is absent or hidden from this caller; 400 for an announcement, which takes no replies by TYPE and no retry fixes; 409 for a locked thread, where the request is well-formed and the resource's state is what refuses. Locked refuses staff too — they hold
unlock, so unlock/post/relock reaches the same place leaving three ledger rows that say what happened, whereas a moderator's reply in a thread nobody else may answer is the last word by fiat.
Under /player for the same reason as §2.11: a forum participant may be a plain player, and the tier
gate is requireAuth. Every route resolves access through the §2.5 resolver — never by checking
membership directly, which is how paths 1 and 3 would drift back together.
5.5 Admin controls — the forum switch and the image policy
Both land in Phase 4 (5a), because a forum that ships without an off switch is one an operator cannot ship at all, and because announcements carry images from the first day the forum exists.
5.5.1 teams_forums_enabled — the operator's switch
A core settings key (VARCHAR(64) key, TEXT value, with updated_by / updated_at recorded by
the existing schema). Default '0' — off. Turned on from Admin → Settings.
Off means guarded, never destroyed. The same principle as the module disabled guard
(MODULE_API.md §4.5): routes stay mounted and answer 404, the forum panel is absent from the
Team page, and the nav entry is filtered by the teams.forums feature flag (§3.5). Not 403 — a 403
says "this exists and you may not have it", which advertises a feature the operator deliberately
turned off; 404 says "not a thing on this site", which is the true statement.
Three things a toggle-off must not do, each because the operator will toggle it back on:
- No data is deleted. Threads, posts, grants and the moderation ledger are untouched. Re-enabling restores the forum exactly as it was.
- No subscription is cleared. The forum notification streams are suppressed at the recipient-computation step (§6.2), not by unsubscribing users. An operator switching the forum off for a fortnight must not silently wipe everyone's notification preferences.
- No grant is revoked.
team_forum_grantsrows survive and stay authoritative for path 3; they simply have nothing to grant access to while the switch is off.
5.5.2 teams_forum_images — the image policy
A core settings key with three values, default 'disabled':
| Value | Uploads | A remote image URL in a post |
|---|---|---|
disabled (default) |
rejected | stays a plain link |
remote |
rejected | link plus the image rendered beneath it |
uploads |
allowed, to this server's /uploads |
link plus the image rendered beneath it |
One judgement call, flagged rather than buried.
uploadsimplies remote rendering too, so the three values are an escalating scale. An operator might reasonably want the opposite pairing — uploads (which they host, moderate and can delete) but no hotlinking (which they cannot control and which leaks viewer IPs, §5.5.3). That combination is not expressible here. It costs one extra enum value (uploads_only) if wanted; it is left out because it was not asked for and every value is a state the UI, the renderer and the tests all have to cover.
The setting is a ceiling, not an assignment. Nothing else may widen it. That is stated now so a later per-Team image preference — if one is ever wanted — can only ever be more restrictive than what the operator allowed, and needs no rethink of this key.
5.5.3 Enforcement: the author never writes an <img> tag
This is the load-bearing decision of the whole section, and it is not how the rest of the site works.
Core's shared sanitizer (utils/sanitizeHtml.js) already allows <img> from any http/https
host — allowedTags includes it, allowedSchemesByTag permits both schemes, and the file's own
comment says the profile is "tuned for rich-text content from the admin editor". Handing cleanBody
to arbitrary players would make teams_forum_images unenforceable: every post could hotlink in
every mode, and the setting would be decoration.
So the forum uses its own sanitizer profile, derived from the shared one, in which img is
never an allowed tag, in any mode. What an author writes is a URL. What decides whether it
becomes a picture is core's renderer, at render time:
author types: https://example.com/banner.png
stored HTML: <a href="https://example.com/banner.png" rel="noopener noreferrer nofollow">https://example.com/banner.png</a>
rendered: that link, and — in `remote` / `uploads` mode only — a core-generated
<img src="…" loading="lazy" referrerpolicy="no-referrer" alt=""> beneath it
Five properties fall out of that, and they are the reason for the design:
- The policy is enforceable, because the only code that can emit an
<img>is core's. - Flipping the setting back to
disabledretroactively un-renders every image, on every existing post, with no data migration — the images were never in the stored HTML. - No attribute smuggling. There is no author-supplied
srcset,onerror,width=99999,style, or anything else; core emits a fixed attribute set. - The link always survives. A blocked, dead or 404ing image degrades to the URL the author actually wrote, which is what the reader wanted anyway.
- It matches how forums conventionally behave, which is what the ask described.
Concrete rules for what gets embedded:
https:only. CSP isimg-src 'self' data: https:(config/csp.js:52) — anhttp:image is blocked by the browser and renders as a broken picture, so anhttp:URL stays a plain link. This is a real mismatch with the shared sanitizer, which permitshttpforimg, and it is exactly the sort of thing that presents as "images are broken on my forum" with nothing in any log.- Extension allowlist on the URL path:
.png .jpg .jpeg .gif .webp .avif. Anything else stays a plain link. Conservative on purpose — guessing wrong renders an<img>pointed at a non-image. referrerpolicy="no-referrer"andloading="lazy"on every generated tag.- Never proxy or cache a remote image server-side. The moment the server fetches a user-supplied
URL it is an SSRF vector — core already carries the guard pattern for that
(
pushDispatch.isAllowedEndpoint), and an allow-set is useless here because the whole point is arbitrary hosts. The browser fetches; the server never does. Written down so nobody adds a proxy "for performance" later. - The privacy cost is stated in the admin help text, not hidden: a remote embed makes each
viewer's browser contact a third-party host, disclosing their IP and User-Agent to whoever runs
it.
no-referrerlimits what else leaks; it cannot prevent the request.
5.5.4 uploads mode: what has to harden first
The existing upload path (router/v1/admin/imageUpload.js) is already good for an admin: an 8 MB
cap, a mimetype allowlist, a random filename, an extension derived from the mimetype map and
never from originalname, and X-Content-Type-Options: nosniff forced on serve (app.js:107). All
of that is kept.
What it does not have is anything that assumes a hostile uploader — because until now it has not had one. Four additions, all in Phase 4:
- Magic-byte sniffing.
file.mimetypeis the client'sContent-Typeheader. A player can sendimage/pngwith arbitrary bytes and land arbitrary content under a.png. Trusted from an admin, not from a player: sniff the leading bytes on write and reject on mismatch with the declared type. - Quotas and rate limits. A per-user upload rate limit (core's
rateLimitmiddleware), a per-post attachment cap, and a per-user daily byte quota. Community uploads with no cap is a disk-exhaustion vector on the operator's own host. - Attribution. A
team_forum_uploadstable (§5.2a) recording uploader, team, post, byte size and stored filename. This is not bookkeeping — the acknowledgement below is meaningless if "who uploaded this" cannot be answered. - Lifecycle. Deleting a post soft-deletes its uploads; a nightly sweep removes files whose rows are soft-deleted past a retention window, and orphaned files with no row at all. The existing admin upload path never deletes anything, which is fine at admin volume and is not fine here.
5.5.5 The acknowledgement
Required to select uploads, and only uploads — that is the mode where third-party material
comes to rest on the operator's own disk. remote gets a non-blocking advisory in the settings help
text instead, since nothing is stored, though the operator is still displaying it.
It is recorded, not merely displayed. An acknowledgement nobody can produce afterwards is
decoration. One settings key does the whole job, because settings already stores updated_by and
updated_at per key:
teams_forum_uploads_ack = "<warning text version>" "1" — the wording in §5.5.5
→ updated_by = the admin who acknowledged (existing column)
→ updated_at = when (existing column)
Plus an activity_log row (team.forum.uploads.acknowledged), so it lands in the staff audit trail
with the acting admin's IP alongside every other consequential admin action (§2.7).
Server-side is the gate. PUT of teams_forum_images = 'uploads' is rejected 400 unless the
same request carries acknowledge: <currentVersion>. A checkbox in the admin UI is not the gate — it
is how the gate is presented.
The warning text is versioned. If it is ever reworded, the stored version no longer matches and the acknowledgement is stale. What happens then matters, and neither obvious answer is right: silently downgrading a live feature because a legal text changed strands users mid-conversation, and honouring a stale acknowledgement forever defeats versioning. So: uploads keep working, a persistent admin banner requires re-acknowledgement, and no other forum setting may be saved until it is given. Non-destructive, and impossible to ignore.
The wording, version 1 — supplied by the org lead 2026-08-17 and normative for the build. It is two surfaces, not one, and the split matters: the first is always on screen and explains what the setting is, the second appears only at the moment of change and is what the acknowledgement records.
(a) Settings help text — rendered beneath the image-mode selector at all times, in every mode:
Image uploads are disabled by default.
Enabling uploads allows users to store files on infrastructure that you control.
By enabling this feature, you acknowledge that you are responsible for:
- Moderating uploaded content
- Managing storage and backups
- Complying with applicable laws and regulations
- Establishing policies for your community
Runic Gateway does not provide hosted storage or content moderation services. All uploaded content is stored on your own infrastructure.
(b) Confirmation dialog — shown only when changing the mode to uploads:
⚠ Image uploads are currently disabled.
Enabling uploads will allow users to store files on your server.
☐ I understand that uploaded files will be stored on infrastructure that I control. ☐ I understand that I am responsible for community moderation policies on this installation.
Cancel·Enable uploads
Two checkboxes, one recorded acknowledgement. Enable uploads stays disabled until both are
ticked, but the request still carries a single acknowledge: 1 and the stored value is still the
text version. Recording two booleans would add nothing — there is no reachable state where an
operator consented to one clause and not the other and proceeded anyway — while the version is what
actually answers the question that matters later: which text did they agree to?
Settled 2026-08-18 (org lead): all three additions below are IN, and the build ships them — 1 and 3 in the help text, 2 in the dialog.
Three additions proposed on top, marked so they can be dropped. Each closes a gap the text above does not currently cover; none is liability language, so none changes what is being agreed to:
- In the help text, after the bullets — the reassuring counterpart, and the reason §5.5.4's
attribution table exists at all:
Uploads are attributed to the account that made them, and your staff can remove them at any time.
- In the dialog — the expectation gap most likely to bite. An operator who turns uploads off
because of a problem will assume the problem goes with it, and it does not:
Disabling uploads later stops new files being accepted. It does not delete files already uploaded — remove those from the forum moderation tools.
- In the help text — the blast radius, since "users" is doing a lot of work. Forum access is not
the same as game membership (§2.5 path 3), so this genuinely surprises:
Anyone with access to a team forum can upload, including members granted access manually who have no linked game account.
Not covered by this text, and needing its own line: remote mode. The wording above is
upload-specific, and correctly so — nothing is stored in remote mode, which is why it takes a
non-blocking advisory rather than an acknowledgement. It still needs one, because the operator's
server is doing the displaying:
Images hosted elsewhere are loaded by each visitor's browser directly from the site hosting them. That site can see your visitors' IP addresses, and you do not control whether the image changes or disappears.
5.5.6 Publication
teams_forums_enabled and teams_forum_images join settings.getPublic()'s PUBLIC_KEYS allowlist
— the client needs the first for the nav feature flag and the second to decide whether to show an
upload control in the composer. Neither is sensitive. teams_forum_uploads_ack is not published:
who accepted a liability notice is operator detail, exactly as failure_reason is in
MODULE_API.md §2.9.
The rendering decision is still made server-side. The client is told the mode so it can present the right composer; it is never the thing that decides whether an image appears.
5.5.7 teams_forum_edit_window_minutes — how long an author may edit (phase 5)
An ordinary settings key, 0–1440, default 15, on the same admin screen as the other two. Set
to 0 it makes posts permanent once written, which is a legitimate operator choice rather than an
off switch — there is no state in which editing is "disabled" as opposed to "bounded at zero", and
inventing one would only give the resolver a decision to get wrong.
Staff are not bound by it. The window exists so a post cannot be rewritten out from under someone
quoting it, or under a moderator about to act on a report; a staffer editing another member's post is
already an intervention that writes activity_log (§5.3), and time-bounding it would only mean
waiting.
It is evaluated on the server twice, on purpose. The read path stamps every post with canEdit
and editableUntil so a client knows whether to draw the control; the write re-derives it from
created_at before allowing anything. Two evaluations of one rule: the read one is advice and the
write one is enforcement. A client may use editableUntil to WITHDRAW an offer whose deadline passed
while a page sat open, and can never create one — a time-bounded permission must not take its clock
from the party it bounds, which is why the window itself is not a published setting (§5.5.6) and is
served only to the admin screen that edits it.
A hidden or deleted post is editable by nobody, staff included. Restoring it is a moderation action with a ledger row; quietly rewriting it while it is out of sight is the same act with no record.
The read fails closed to zero, not to the default — the opposite of what it looks like it should do. The risk the window bounds is an author rewriting a post out from under a reader, so the safe answer during a DB fault is "nobody may edit for the next minute". A stale uploads acknowledgement freezes this key along with the other two: it is a forum setting.
5.6 Abuse reports — the missing half of moderation
Core has no user-facing report flow of any kind today. moderation, mod_notes and appeals are
all either staff-initiated or Discord-sanction-shaped; nothing anywhere lets a member say "this is a
problem". That was survivable while every piece of content on the site came from staff. It stops being
survivable the moment §5.5's uploads mode lets players put files on the operator's disk under a
signed liability acknowledgement.
The gap has a specific shape worth naming: leaders moderate their own Team's forum, and a Team's leaders are exactly the people who will not report their own Team. A private Team forum with uploads enabled, no report control and no staff visibility is a space the operator has formally accepted responsibility for and has no mechanism to learn about.
-- Deliberately generic. Team forum content is the first consumer; wiki pages,
-- news comments and profile fields can be added as target_type values with no
-- schema change.
CREATE TABLE IF NOT EXISTS content_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload'
target_id BIGINT NOT NULL,
team_id INT NULL, -- denormalised for the queue's filters
reporter_user_id INT NULL,
reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL,
detail VARCHAR(500) NULL,
status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open',
handled_by INT NULL,
handled_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, status),
INDEX idx_cr_queue (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Amended 2026-08-18 (phase 5). The table as shipped departs from the block above in four places, three of them corrections and one an addition.
The unique key is on a generated
open_marker, not onstatus, and the spelling above has a defect worth recording rather than quietly fixing. Withstatusin the key, CLOSED rows collide with each other too: a reporter reports a post, staff dismiss it, the behaviour recurs, they report it again — and the second dismissal is anUPDATEinto a(…, 'dismissed')tuple that already exists, so working the queue starts throwing duplicate-key errors on the first repeat reporter. The shipped column isopen_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED, the same trickteam_forum_grants.active_markeruses: 1 while open, NULL once closed, and MySQL treats NULLs as distinct — so any number of closed reports coexist while at most one open one can. That is what the prose above actually asks for.
handled_note VARCHAR(500)was added. §5.6's API takes{ status, note? }and the table had nowhere to put the note. A queue whose resolution reason lives only in anactivity_logline is one where the next staffer to see a repeat report about the same content cannot find out why the last one was closed.
reporter_usernameandhandled_usernamesnapshots were added, per §2.10: who raised a report and who decided it must survive the account, exactly as every other Team table already does.
team_idgained a real FK withON DELETE CASCADE. The block above leaves it a bare denormalised column; a deleted Team then leaves a queue full of reports about content that cascaded away with it.Every transition writes
activity_log,dismissedincluded. A queue where acting is audited and declining to act is not is one where the cheapest way to make a report vanish leaves no trace — and the reports most worth auditing are exactly the ones somebody wanted gone.
Four rules:
- Reports go to site staff, and to nobody else. (Amended 2026-08-18, org lead, when phase 5 was
built.) This section originally added "a leader may also see and act on reports for their own
Team". That half is not implemented and is not deferred — it is decided against. The gap this
whole section exists to close is that leaders moderate their own Team's forum and a Team's leaders
are exactly the people who will not report their own Team; a leader-visible queue hands a complaint
about a leader straight back to them, and a read-only leader view still tells them who reported
what. There is one queue, under
/admin/moderation, gated to admin + moderator. If a leader-facing surface is ever wanted it is a fresh design decision, not a refactor —content_reports.team_idmakes it possible, which is not the same as intended. - Reporting is not a moderation action. A report changes nothing about the content; it opens a queue item. This keeps it clear of §5.3's leader/staff moderation ledger, which records things that actually happened.
- Rate-limited and deduplicated. One open report per (target, reporter) — the unique key — plus
core's
rateLimitmiddleware, so the queue cannot be used as a harassment tool. - Reports on uploads carry the
team_forum_uploadsrow, so a staffer sees uploader, size and sniffed type without hunting. This is why §5.5.4's attribution table is load-bearing rather than bookkeeping.
POST /api/v1/player/teams/:slug/forum/report { targetType, targetId, reason, detail? }
GET /api/v1/admin/moderation/reports the queue, alongside the existing appeals queue
POST /api/v1/admin/moderation/reports/:id/handle { status, note? }
The player route sits behind the same resolveForum guard as the rest of §5.4, so a reporter is by
construction someone who can already see what they are reporting — and the model additionally checks
the target really belongs to the Team the request came through, or the queue's per-Team filter would
quietly be lying. A duplicate answers 409 rather than pretending to succeed: silently accepting is
friendlier for one tap and dishonest for the second, and a member who reports twice because nothing
seemed to happen deserves to be told the first is already in the queue.
The queue resolves every row's target in three batched reads keyed by target type, never one read
per row — that is rule 4 above actually paying for §5.5.4's attribution table, and the N+1 version is
how a queue becomes a thing staff avoid opening. A target that has since been hard-deleted comes back
as null and the report still lists: "somebody reported this and by the time we looked it was gone"
is a fact a moderator needs, and dropping the row would hide the pattern of a member deleting their
own content the moment it is reported.
Mounted under the existing admin moderation section rather than under Teams: a staffer working a queue should have one place to work, and a report about a forum post is the same job as a report about anything else.
5.2a Upload attribution table (lands with §5.2)
CREATE TABLE IF NOT EXISTS team_forum_uploads (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
post_id BIGINT NULL, -- NULL while a draft upload is unattached
user_id INT NULL, -- SET NULL on account delete; attribution survives in activity_log
filename VARCHAR(191) NOT NULL, -- the stored random name, not originalname
byte_size INT NOT NULL,
mime VARCHAR(64) NOT NULL, -- the SNIFFED type, not the declared one
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL, -- soft; the nightly sweep removes the file
CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL,
CONSTRAINT fk_tfu_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uq_tfu_filename (filename),
INDEX idx_tfu_user (user_id, created_at),
INDEX idx_tfu_sweep (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Part 6 — Notifications
Built 2026-08-18 (phase 6). As-built, and it deviates from what is written below in four places. Each is recorded here rather than by rewriting the section, so the reasoning that produced the original design stays legible next to what the build learned:
- There was no web notification settings screen to add the Team list to. §6.3 says the per-Team mute list is surfaced "under the existing notification settings screen". No such screen existed:
/auth/me/notifications/*had been built for the Android app in M7 and had zero web consumers. Tolerable while push was the only sink — push needs the app anyway. Not tolerable for email, whose entire argument (§6.4) is the web-only user, so the sink and the screen to configure it shipped together as/account/notifications.- Email defaults to
off, not todigest. §6.4 specifies digest-by-default; on the org lead's decision it is opt-IN, because digest-by-default means every member of every Team starts receiving daily mail the moment an operator connects Gmail — a decision about other people's inboxes, made on their behalf. Push stays opt-out. The two sinks now default opposite ways; the asymmetry lives in the schema's column defaults and nowhere else.- Roster events do not email. All four streams exist and all four tickle. Only the two forum streams reach the email sink: §6.4's argument is the reply nobody hears about, and "someone joined the guild" arrives from a fifteen-minute sweep, is already on the activity feed, and is how a notification feature earns a spam complaint.
- A ninth member joined
MODULE_API_VERSION1.6.0 —pageUrlTemplateon the team provider. Phase 3 left core with no Team page and therefore no way to link to one, so an email could name a Team and not take you to it. The module that owns the page now says where it is. SeeMODULE_API.mdregisterTeamProvider.Two further build decisions, neither contradicting anything above: the digest computes at send time and keeps no queue (§6.4 as-built, below), and one-click unsubscribe is a stateless HMAC rather than a token table.
6.1 What the existing pipeline gives us, and the one thing it does not
Reusable unchanged: ntfy itself (a compose service, declarative config, no per-user accounts), the
device registry (push_devices), the opt-in model (notification_subscriptions), the content-free
tickle, the SSRF allow-set, and pushDispatch.publish.
The gap is §0.5: publish fans out to all subscribers of a stream or to one ownerUserId.
Team notifications need "these N users".
6.2 The design: fixed streams, computed recipients
Do not create a stream per Team. The catalog is a static registration validated at boot with a
namespaced-id pattern; it cannot express an unbounded, runtime-created set, and stream ids are stored
in notification_subscriptions rows that would then need garbage-collecting when a Team archives.
Instead: four fixed core streams, and Team scoping lives entirely in the recipient set.
// server/src/config/coreStreams.js — added alongside news.post
{ id: 'team.member.joined', label: 'Team — new member' }
{ id: 'team.leadership.changed', label: 'Team — leadership change' }
{ id: 'team.forum.post', label: 'Team — new forum post' }
{ id: 'team.announcement', label: 'Team — announcements' }
// all: personal: false, requiresLinkedAccount: false
requiresLinkedAccount: false is correct and slightly counter-intuitive: a forum-granted user with no
game account is a legitimate recipient of team.forum.post. The recipient computation, not the stream
flag, is what enforces who gets what.
One new dispatch signature and one new query:
// utils/pushDispatch.js
publishToUsers(streamId, { ref, userIds }) // honours each recipient's own subscription
// model/pushDevices/pushDevices.db.js
endpointsForUsersStream(userIds, streamId) // WHERE user_id IN (?) AND subscribed
Recipients per event:
| Stream | Recipients |
|---|---|
team.member.joined |
active members with user_id, plus active forum grants |
team.leadership.changed |
same |
team.forum.post |
everyone with resolved forum access to that Team, minus the author |
team.announcement |
same, minus the author |
The tickle stays content-free — { stream: 'team.forum.post', ref: 'team:3:thread:41' } — and the app
pulls the real content over the authenticated, access-checked API. ntfy remains an untrusted relay and
needs no change of any kind.
6.3 The one new granularity: per-Team mute
Per-stream opt-in already exists and is per-user. The thing it cannot express is "I'm in five Teams and want notifications from one". One small table, opt-out rather than opt-in, so a user in a single Team never has to configure anything:
CREATE TABLE IF NOT EXISTS team_notification_prefs (
user_id INT NOT NULL,
team_id INT NOT NULL,
muted TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, team_id),
CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Applied as a subtraction from the computed recipient set — in SQL, not in the caller: there is no
function in model/teams/teamNotify.db.js that returns an unfiltered recipient list, because one
would be a refactor away from being used.
As built, the column is email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off' plus
a last_digest_at DATETIME NULL, and it is surfaced in two places:
last_digest_atis no longer read (engagement Phase 6). The digest's state moved toengagement_digest_state, keyed(user_id, channel, scope_key)so a second digest needs no second column here;schema.sqlbackfills it once. The column stays as the backfill's source and as the record of what a row meant before the migration. The two preference columns are unchanged and are still the authority for Team notifications — the engine reads them rather than replacing them.
/account/notifications, a new core page in the player portal — stream subscriptions, the per-Team mute list, and the email mode per Team.GET|PUT /auth/me/notifications/teams; theteamsarray is required on PUT even when empty, per the Android gotcha below.- A mute toggle on the Team page, filled into a THIRD module-declared slot,
uo.guild.header. Above the roster rather than below it, because muting is an action on the page and the feed and forum are content in it — which is exactly the placement decision a module cannot make if core stacks everything into one fill. It renders nothing for a viewer with no preference row available, which is a privacy property and not a tidiness one: whether a preference exists for a Team answers "is this person in it", and the guild page is public.
6.4 Email — the third sink
Push needs the Android app. The Discord bridge (§7.2) needs Discord. A web-only user on a deployment running neither currently gets no notification that someone replied to their own thread — which is most users on most deployments, and a forum where replies are invisible is a forum nobody returns to.
Core already has utils/mailer.js and an admin-configured email_config. The expensive part of
notifications is computing the recipient set, and §6.2 builds it; email is a third consumer of the
same event, not a fourth pipeline.
Rewritten 2026-08-29 — the engagement system's Phase 6 took this sink over. Everything below the line still describes what a recipient receives; what changed is who decides to send it. The design of record for the mechanism is now
docs/website/ENGAGEMENT.md(Phase 6 as built), and this section is the Teams-shaped view of it. Do not re-specify the engine here — the same rule §6.0b applies to every other doc that touches a contract it does not own.
What moved, and what did not
teamNotify.js had three sinks. One moved:
| Sink | Where it lives now |
|---|---|
| The content-free push tickle | still teamNotify.js, unchanged. Its deliver on the engine is the engagement Phase 7's, with the in-app inbox that gives a tickle a ref worth deep-linking |
| The Discord bridge (§7.2) | still teamNotify.js, unchanged. A bridge is a leg — one-shot, to whoever can read a channel — and not a per-recipient channel; ENGAGEMENT.md §3.1 argues that distinction and it holds here |
the engagement engine. teamNotify.forumPost emits team.forum.post / team.announcement; a rule decides who is mailed, through which template, how often at most |
mailer.sendTeamNotification and teamNotify.emailImmediate no longer exist. The mail body is an
engagement_templates row an operator can edit (notify.team-post for a post, notify.digest for the
digest, notify.event for the two roster events and for announcements).
The four properties this section always claimed, and where each one lives now
- Same recipient computation, same per-Team mute, same suppression while
teams_forums_enabledis off. All three still hold, and the first is now explicit rather than incidental:teamNotify.recipientIdscomputes the access-checked set and it travels on the event envelope asrecipientUserIds. A rule whose audience ismembersresolves to exactly that set — still filtered forusers.status = 'active', still under the trigger's ceiling. Core does not learn what a Team is; the event says who it is about. - Unlike a push tickle, an email carries content — the same reasoning as the Discord bridge (§7.2): the recipient's mailbox is a destination they chose, not an untrusted relay reached by an unguessable topic. It carries the thread title, an excerpt and a link; never the full post.
- The per-Team preference is unchanged and is still the authority.
team_notification_prefsstays exactly where it is, with exactly the meaning §6.3 gives it. The engine reads it through a scoped-preference adapter: for a Team-scoped event that table is the preference,mutedsilences every channel, andemail_modedecides email and says nothing about the others. The alternative — intersecting it with the newer per-stream preference — would have silenced every existing subscriber on the migrating deploy, because nobody has ever expressed a stream-level opinion about a Team trigger. The argument in full is in ENGAGEMENT.md Phase 6. - Off unless email is configured. No usable
email_configmeans the sink is absent, not broken — and as of Phase 6 there is a second gate above it, below.
Team email is OFF until an operator turns it on
This is the one live behaviour change and it is deliberate. An engagement rule arrives enabled = 0 so
that no import, restore or upgrade can start mailing on its own, and core seeds four Team rules under
that same rule. On upgrade, Team notification emails stop until somebody opens Admin → Engagement →
Rules and switches one on. The screen carries a banner saying so for as long as every Team rule is
off; the release note says it too. Push and the Discord bridge are unaffected.
The digest
Computes at send time and keeps no queue. The worker asks what arrived after the last stamp and re-runs the access resolver. Three properties fall out, and the third is why it was chosen over a pending-items table — and, in Phase 6, over the engine's own outbox:
- a deployment down for two days sends one correct digest rather than replaying a backlog;
- a post a moderator hid after it was written is simply not in the query;
- a user who lost forum access between the post and the send is no longer in the recipient set, so they are not emailed content they can no longer read.
since is clamped to at most seven days so a long outage cannot produce one enormous mail, and the
stamp is written only on a successful send — stamping first would quietly eat a day of somebody's
notifications every time the mail provider had a bad minute.
What Phase 6 changed is the state, not the design. The stamp moved from
team_notification_prefs.last_digest_at into engagement_digest_state, keyed
(user_id, channel, scope_key), backfilled once by schema.sql. A digest-mode recipient gets no
outbox row — a row would carry a snapshot taken at publish time and would have none of the three
properties above. The worker is also gated on an enabled email rule, so switching Team email off
switches off both halves of it rather than the instant half only.
- Digest, not per-event, when email is on at all. A busy Team forum sending one email per reply is
how a notification feature gets marked as spam.
email_mode ENUM('off','digest','immediate')inteam_notification_prefs.As built, the default is
offand notdigest(org lead, 2026-08-18): digest-by-default would start mailing every member of every Team the moment an operator connects a mail transport. Email is the one opt-IN sink here. Push stays opt-out, because a mute silences something the user already has. - Roster events do not email by default (as built, restated by Phase 6).
team.member.joinedandteam.leadership.changeddo now emit, so an operator who wants that mail can have it — but the rules that would send it are seeded disabled and carry an hour-long cooldown, so §6.4's original argument survives as the default rather than as a sink the code declines to call.
One-click unsubscribe
A link honouring the same per-Team preference, so an unsubscribe from the mail client writes what the site shows.
A stateless HMAC, not a token table. Every property that makes a password-reset token a row is absent here — the link sits in a mailbox for months so it has no useful expiry, and clicking it twice must mean what clicking it once meant. The capability is deliberately the narrowest that does the job: turn one channel off for one scope for one account. It reads nothing, cannot turn anything back on, and names no other scope.
versionis the only revocation a stateless design can offer — retiring one invalidates every outstanding link of it at once — and it exists before it is needed rather than after.Phase 6 generalized the token from
(userId, teamId)to(userId, channel, scopeKey), and narrowed what it does. A v1 token setmuted, which silenced that Team's push as well as its email — a link labelled "stop these emails" quietly stopping notifications on somebody's phone. A token now turns off the channel it names and nothing else. Old tokens still verify, permanently, and read as the email channel for that Team, which is a reading of what they always meant.Two URLs come out of one token, and they are not interchangeable. The mail body carries the site's own
/unsubscribe/:tokenpage, which POSTs once a human is looking at it. TheList-Unsubscribeheader carriesPOST /api/v1/public/engagement/unsubscribe/:token, because RFC 8058 lets a client POST to it without rendering anything. A GET on the API path redirects and does not act — a mail client's link scanner would otherwise silently unsubscribe people who asked for nothing. The endpoint answers200whatever the token was: a response that distinguished a valid token from a forgery would be an oracle for which (user, scope) pairs exist, on a surface with no session behind it.
POST|GET /api/v1/public/teams/unsubscribe/:tokenstill exists and always will. It hands straight to the same handlers. Mail sent before Phase 6 carries that path in its header and in its body, mail is not editable once sent, and a route that moves is a person who cannot unsubscribe.
Folded into Phase 6 rather than getting a phase of its own: the recipient set is the work, and it is already being built there.
Android gotcha, carried forward. The existing PUT-the-whole-set endpoints require the array field even when empty (
docs/android/PLAN.md§11). Any new "replace the set" endpoint here must be specified the same way, and the app-side DTO field must have no default, or kotlinx drops it and clearing the last entry 400s.
Part 7 — Discord integration
7.1 Slash-command registration
Amended 2026-08-18 (phase 7), as built. Five changes, four of them forced by what the tree already looked like.
The example command is
/guild, registered by module-uo, and core registers none. §7.1 wrote/teamas a core command against core's own Team rows. Phase 3 settled that Teams is a contract primitive with no core surface — core does not own the word for a Team, which is why four core Team pages were deleted — and a core/teampublishes that same invented noun into a channel. The module owns the vocabulary, so the module owns the command. Core ships the dispatcher, the actor resolver and the transport, and zero commands.The deep link comes from
pageUrlTemplate, because/teams/:slugdoes not exist. The snippet below still says${siteBaseUrl}/teams/${slug}; there is no such page. A handler builds its own link — module-uo's is/uo/guilds/{externalId}— which is the same hole phase 6 found in the mail path and closed with the ninth contract member.The re-register nudge is its own endpoint,
POST /internal/refresh-commandson the bot, not a ride on/internal/config. That body carries the DECRYPTED bot token: telling the bot that a module changed should not require reading a secret out of the database to say it.
actorcarriesroleas well asisStaff. The two answer different questions and a boolean loses one —isStaffis core's gate foraccess: 'staff',roleis what a module with its own audience rungs needs to place the caller on them. It is the pairprojectRoster's viewer already carried (§3.3), not a new class of disclosure.Deregistration needed a second half this section did not consider. "A module that is gone is simply absent from the next pull" holds across the restart an uninstall asks for. It does not hold for the runtime toggle: the registries have no removal path, so a module an operator disables would keep a live handler behind a command Discord still advertises. Liveness is therefore asked at both the pull and the dispatch, and a disabled owner's command answers
unknown.The envelope also gained
notice— a private aside delivered beside a public answer, which is how §9 answer 5's "public projection plus an ephemeral prompt to link" is actually expressible: one reply cannot be both public and ephemeral, and that it becomes a follow-up is the platform's decision, not the handler's.
Ownership, decided: the registrant owns the definition and the handler; the handler runs in
the website process and returns a response envelope; the bot owns every Discord-specific
concern — deferral, the 3-second ack, ephemerality, follow-ups, interaction tokens, embeds. This is
forced by §0.4 (the bot cannot load module code) and it is also the right boundary: a module writing
interaction.deferReply() would be a module holding a Discord handle.
api.registerSlashCommands([{
name: 'team',
description: 'Show a team summary',
options: [ // restricted schema, §7.1.1
{ name: 'name', type: 'string', description: 'Team name or abbreviation', required: false },
],
access: 'everyone', // 'everyone' | 'linked' | 'staff'
async handler({ command, options, actor }) {
return { text: '…', fields: [ … ], url: `${siteBaseUrl}/teams/${slug}`, ephemeral: false }
},
}])
actoris resolved by core before the handler runs:{ platform: 'discord', platformUserId, guildId, userId | null, isLinked, isStaff }.userIdcomes fromuser_identities. A handler never parses a Discord payload and never learns anything platform-shaped beyondplatform.accessis enforced twice — the bot sets Discord-side default member permissions from it where it can, and core re-checks it in the dispatcher, which is the actual gate. Client-side is about not advertising a dead end; the server is the boundary. Same principle as the nav.
7.1.1 The option schema is deliberately small
string | integer | boolean | user, each with required and optional choices. No subcommand
groups, autocomplete, attachments, modals or component interactions in v1. Those are exactly the
features whose semantics do not survive a second platform, and admitting one of them into the
registration API is how Discord specifics leak in by accident. A command needing more is a bot-side
command, written in the bot, and that stays available.
7.1.2 Transport
bot → app GET /internal/commands → { version, commands: [ {name, description, options, access} ] }
bot → app POST /internal/commands/dispatch → { text?, embed?, fields?, url?, ephemeral? }
On the app's existing unpublished internal listener (port 3001), behind the existing
requireInternalKey. /internal/* is #swagger.ignored today and stays so — it is not public API.
Lifecycle. The bot pulls /internal/commands on ready and merges the result with its own static
array before the single REST.put(applicationGuildCommands) it already does. Because that call is a
whole-set PUT, deregistration on module unload is free — a module that is gone is simply absent
from the next pull. Core exposes a version counter bumped on any module state change (the same trick
modules.version() already uses for the OpenAPI cache); the app pushes a re-register nudge through the
existing /internal/config path when it changes, and the bot re-pulls.
Timing. The bot defers immediately on receiving an interaction (well inside Discord's 3s ack
budget), then POSTs the dispatch with a 4s timeout — the same budget botInternalClient already uses —
and edits the deferred reply with the envelope. The website is never in the 3s critical path.
Failure isolation. The dispatcher try/catches per handler; a throw becomes
{ ok: false } and the bot posts an ephemeral "that command failed". A timeout is identical. A module
whose handler wedges costs its own command and nothing else — the bot process is never at risk because
the handler does not run in it. Cross-module isolation is not required (single-module deployment) and
none is built.
The example command. /team [name] → member/linked/online counts, leaders, and a deep link to
/teams/:slug. For a caller with no linked account it returns the public projection plus an
ephemeral "link your account for more" — see §9 answer 5.
7.2 Notifications bridge
Amended after building it (phase 8, 2026-08-18). The shape below is what was designed; five things about it did not survive contact with the tree, and the amendments are inline. The largest is that this section's own visibility gate has no data source and cannot have one — see "The gate, as built" below. The phase entry in Part 12 carries the full list.
The same Team events as §6, delivered to a second consumer. Core emits each Team notification to an internal fan-out with two subscribers: push (§6) and the integration bridge. Not a second pipeline — one event, two deliveries.
As built, there is no new fan-out object:
utils/teamNotify.jsalready computed the recipient set once and handed the event to push and to email, so the bridge is a third sink in that same file rather than a subscriber to something new.utils/teamBridge.jsis the sink; the file that calls it is unchanged in structure.
CREATE TABLE IF NOT EXISTS team_integration_config (
platform VARCHAR(32) NOT NULL, -- 'discord'
team_id INT NULL, -- NULL = the deployment-wide default
events JSON NOT NULL, -- ['team.announcement','team.forum.post']
channel_ref VARCHAR(64) NULL, -- destination on that platform
enabled TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (platform, team_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
This DDL cannot hold its own default row. MariaDB coerces every
PRIMARY KEYcolumn toNOT NULL, soteam_id NULL— the deployment-wide default that every override overrides — is unrepresentable, and the whole mechanism has no base case. As built: a surrogateidprimary key, a generatedteam_key INT AS (IFNULL(team_id, 0)) STOREDcarryingUNIQUE KEY (platform, team_key), and a realFOREIGN KEY (team_id) … ON DELETE CASCADEthat the original had no room for — without it a deleted Team leaves its configuration behind for whichever Team next lands on that id. The generated-column trick is the oneteams.active_keyandcontent_reports.open_markeralready use. Three further columns carry the gate:members_ack,members_ack_byandmembers_ack_at.
Admin-configurable per event type, globally and per Team (a per-Team row overrides the team_id IS NULL default). Delivered via POST /internal/team-notify on the bot, best-effort, never throwing —
identical to announce and mod-reverse.
announceandmod-reverseare not the same thing.announceridesannounce_jobswith backoff, retries and a per-leg retry button in the admin panel;mod-reverseis a one-shot call that records failure and stops. The bridge is one-shot. A news post is a durable artifact whose Discord copy is expected to exist; a Team notification is the moment it describes, and one that arrives twenty minutes late is worse than one that never arrives. A second job table and a second worker is a great deal of machinery to buy the opposite outcome.The admin surface is its own panel under Admin → Teams, beside the forum settings, and not an extension of the Discord Bot panel — a second integration would make the platform a registry lookup (§8.2), and what should change then is what fills the panel, not where it is. That holds whether or not the capability layer is ever built; Phase 10, which would have built it, is cancelled. It is admin-only, the one such corner of a staff-wide router: configuring where a Team's content leaves the site for is deployment configuration rather than the §2.9 kind of decision a moderator files a request for.
A Discord message carries content; a push tickle does not. Stated explicitly because the two look
like the same event and are not: ntfy is an untrusted relay reached by an unguessable topic, so the
tickle is content-free by design; the Discord server is an operator-configured, trusted destination
where an empty "something happened, go look" message would be useless. What is shared is the
allowlist discipline — an event is bridged only if its visibility is public, or its destination
channel is configured for a members-only Team context.
The gate, as built
Neither half of that last sentence has a data source, and neither can have one.
- The four
team.*streams carry novisibility. Onlyteam_activityrows do, and a notification is not an activity row. - Forum threads have no public/members column, because a forum is members-only by construction —
every thread in it sits behind
team_forum_grants. So §7.2's own example configuration,['team.announcement','team.forum.post'], names exactly the two events that can never be public. - Core cannot see a Discord channel's permissions, so "configured for a members-only Team context" is not a fact core can check. Only the operator can see it.
So the gate becomes an attributed acknowledgement: enabling an event that carries members-only
content requires an explicit confirmation that the destination channel is restricted to that Team's
members, recorded with who gave it and when — the same shape teams_forum_uploads_ack uses for the
image policy (§5.5.5). Four properties make it a gate rather than a checkbox:
- It is a precondition, not a preference. A save that would enable a members-only event without it is refused 422, not accepted-and-quietly-degraded. A configuration that silently does less than it says is worse than one that will not save.
- It is re-asked at delivery, not only at the save, so a row that loses the tick — an admin repoints it, or a future change reclassifies a stream it already carries — stops carrying those events immediately rather than at the next save.
- Changing the channel clears it. An acknowledgement is about a destination; it cannot survive the destination changing underneath it, or an operator could confirm a private channel and then repoint the row at a public one while keeping the permission granted for somewhere else.
- A roster-only bridge needs no acknowledgement at all, and a disabled row may carry forum events without one — drafting a configuration is not publishing to a channel, and a dialog that appears on saves that did not need it is one people learn to click through.
Two smaller consequences of the same asymmetry:
- The author exclusion stops at the channel. Push and email both subtract the post's author; the bridge does not. Excluding is a per-recipient idea, and a channel has no per-recipient anything — suppressing the message because the author reads that channel would deprive everyone else in it.
- A roster event carries a count and never a name. The sync notifies once per run rather than once per member (§6.2), so a count is all the caller holds. It is also all it should say: a character name is game-sourced text screened for a page, not for a channel.
7.3 One voice channel per Team
Amended 2026-08-19, as built (phase 9). The org lead settled the access model as a per-Team role, always — the escalation below is gone, and with it
voice_overwrite_maxand themodecolumn. Three things this section names turned out not to exist in the tree at all, and one number it relies on counts something different from what it says. Each is marked inline; "as built" wins over the original wording wherever they disagree.
Shape. One voice channel per qualifying Team, under a single shared parent category
(Teams), created by the bot. No per-Team role by default, no auto-created category per Team.
As built: a per-Team role, always. Overwrites-by-default with escalation was designed to spend the scarcer guild-wide resource only where the per-channel budget actually ran out. Roles-always is one code path instead of two plus a transition, and it makes the grant a thing a member can be given and taken rather than a channel-shaped list — but it moves the ceiling, and that is the part worth stating plainly:
overwrites (designed) roles (as built) Limit ~100 overwrites per channel 250 roles per guild So the ceiling is how big ONE Team can be how many TEAMS can have voice Visible to other members no yes — a role shows on a profile A limit on the number of Teams is one an operator has to be told about before they reach it, so the admin panel reports the guild's role count against the cap and the reconciler refuses the create rather than letting Discord reject it. The count comes from the bot, not from core's own rows: the cap is shared with every role the operator made themselves.
That a Team's membership becomes visible guild-wide on each member's profile is the trade this bought. It is not per-deployment configurable.
Access, and why overwrites are enough — with a stated fallback. Access is @everyone deny +
VIEW_CHANNEL/CONNECT allow per linked Team member (path 4, §2.5) + the staff role. Discord's
practical per-channel overwrite budget is ~100. A Team of up to ~95 linked members fits with room for
@everyone and staff. Above a configurable threshold (voice_overwrite_max, default 90) the
integration escalates that one Team to a per-Team role — one overwrite for the role instead of N for
members — because roles are the scarcer guild-wide resource (250 cap) and should be spent only where
overwrites actually run out. So: overwrites by default, role on demand, and the escalation is recorded
in team_integrations.mode.
As built. The channel carries exactly three kinds of overwrite:
@everyonedenied, the Team's own role allowed, and one allow per operator-designated staff role. Membership is the role's member list. There is nomode, novoice_overwrite_maxand no escalation."the staff role" does not exist in this codebase.
guild_configknows a news channel, a modlog channel, an autorole and a filter allowlist; none of them means "staff", and core has no way to derive one. Guild administrators bypass channel overwrites anyway, so what is actually missing is a way to let non-admin staff in — and only the operator can say which of their roles those are. As built:teams_voice_staff_roles, a list of role ids, empty by default and a perfectly ordinary answer. A role the operator has since deleted is filtered out by the bot rather than sent, because Discord rejects an entire overwrite set for one bad id and that would take the Team's own grant down with it.The grant set is hop 3, not path 4's "linked". A role can only be given to somebody Discord knows, so the set is Team members who have a site account and a
user_identitiesrow for Discord and are in the guild. A member missing the last of those is skipped silently — it is §2.6's hop 3 without hop 4, an ordinary state, not an error worth a hundred log lines.
Provisioning gate. Admin opt-in per deployment, plus voice_min_linked_members (default 5).
Counted on linked members only, since an unlinked member cannot be granted anything on Discord
anyway.
As built:
teams_voice_min_members, counting EVERY active member (org lead, 2026-08-19). The question an operator is answering with this number is "is this Team real enough to deserve a channel", and link state answers a different one. Note that this is deliberately notteams.linked_counteither — that column counts hop 1 (has a site account), which is a third quantity again.Two more gates the original does not mention, both required:
- A hidden Team is never provisioned. A channel name is a game-sourced string published outside the site, which is exactly §2.8's concern —
utils/reservedNames.jsalready names "and eventually a Discord channel name" among the surfaces it protects. So the screen that suppresses a Team's public page suppresses its channel, and a Team that becomes hidden takes the grace window like any other removal. The interlock costs onehidden = 0in one query rather than a second policy that could drift from the first. The name published isdisplay_name_override || name— §2.8.3 lets staff change what is displayed, and a channel is a display surface.- The bot must actually be able to act. This section assumes it can manage channels and roles; nothing in this project has ever checked. The operator invites the bot by hand and there is no invite URL with a permission integer anywhere in the tree, so a deployment can sit one unticked box away from every call failing with only a column of identical per-Team errors to show for it. As built, a preflight is a precondition:
PUT /admin/teams/voicewithenabled: trueis refused 422 while the bot is disconnected or missing Manage Channels or Manage Roles, in the same shape §7.2's acknowledgement refuses. It is asked again at the top of every pass. Switching voice OFF is never gated — an operator disabling a feature because it is misbehaving must not be blocked by the misbehaviour.The preflight also reports the bot's own role position, because that is the second, quieter failure: Manage Roles lets the bot create a role, but it can only grant roles below its own highest. A bot at the bottom of the list creates roles it cannot hand to anybody, which looks exactly like a channel nobody can enter.
Lifecycle: delete, but after a grace window. Justification, since the brief asks for one:
- A voice channel holds no message history, so deletion destroys nothing recoverable. The archive-don't-delete caution from the earlier full-category proposal was about text channels with history, and it does not transfer.
- What does transfer is churn. A Team hovering around the threshold — one member unlinks, one rejoins — would delete-and-recreate, changing the channel id, breaking every pinned link to it, and filling the audit log. That is a real harm with no content loss at all, which is exactly the case a grace window fixes and an archive does not.
So: drop below threshold → state='pending_removal', remove_after = now + voice_grace_days
(default 7). Recover above threshold inside the window → cancel, no Discord call made. Still below at
expiry → delete. A Team archived (disbanded or renamed) takes the same window, because "disbanded"
can be a missed event and 7 days is cheap insurance.
As built, with one narrowing. "Recover inside the window → no Discord call made" is not quite what happens, and the truer promise is no DESTRUCTIVE call. A Team that climbed back above the threshold has members who need granting, and the ordinary membership diff is what grants them; refusing to call at all would leave the very people who brought it back outside the channel. What the recovery cancels is the deletion, and the channel id is unchanged — which is the whole point.
A failed teardown keeps the expired window rather than being rescheduled. Granting another seven days each time a delete fails means it never happens.
Switching voice off tears nothing down. The pass suspends in both directions and existing channels are left standing, inert; the panel says how many remain and offers to remove them one at a time. A checkbox must not delete structure in somebody's guild, and an operator trying the feature out must be able to stop trying it without consequences. Per-row removal is also the only way to clean up while voice is off, since no pass will ever reach those rows.
And never on stale data. If team_sync_state is stale for the module (§2.4), the integration
reconciler skips entirely — no creation, no deletion, no overwrite changes. A voice channel is never
destroyed because a sidecar was down.
As built, and proved on the rig — a stale projection stops the pass before a single Discord call, in both directions, with the row not even scheduled for removal.
One boundary worth knowing:
teams.model.syncStatus()reportsstale: falsewhen no Team provider is registered, on the reasoning that a deployment with no game module is not a broken one. So on a deployment whose module has been uninstalled this suspension is inactive — which is benign, because with nothing updating the projection the member counts do not move and the reconciler has nothing to act on.
CREATE TABLE IF NOT EXISTS team_integrations (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
platform VARCHAR(32) NOT NULL, -- 'discord'
resource VARCHAR(32) NOT NULL, -- 'voice'
external_ref VARCHAR(64) NULL, -- the channel id
role_ref VARCHAR(64) NULL, -- the Team's role: the grant itself
state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none',
remove_after DATETIME NULL,
last_error VARCHAR(500) NULL,
synced_at DATETIME NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_team_integration (team_id, platform, resource),
INDEX idx_ti_pending (state, remove_after),
CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
As built —
modeandrole_ref-as-escalation are gone;role_refis now the grant itself, so a row with a channel and no role is a broken row.synced_atis added:updated_atmoves whenever core writes a belief, including an error, and "when did this last actually reach Discord" is a different question. Unlike §7.2's DDL, this one applied to real MariaDB exactly as written.
Sync rides the same reconciliation as membership: after a successful Team reconcile, the
integration reconciler diffs the desired access set (path 4) against what the bot reports and issues
the minimum set of calls. Every call is best-effort; a failure records state='error' with the message
and retries on the next pass. It never blocks the Team sync.
As built, with the diff on the bot's side. Core sends the DESIRED STATE for one Team — name, category, channel, role, staff roles, the member id list — and the bot works out the calls. That is the opposite of the split §7.1 and §7.2 use, and it is deliberate: every decision is still core's, but the diff is a comparison against live guild state that only the bot can see, and doing it in core would mean shipping the guild's whole role membership over the wire to compare it and shipping the answer back.
The membership diff is bounded per pass (50 operations) and the remainder is reported, because each grant is its own API call under its own rate limit and an unbounded first pass on a large guild outlives its own request timeout — the one failure that leaves core not knowing what was applied. A non-zero remainder asks for another pass rather than waiting out the interval.
A failure is per-Team and never aborts the pass, the same shape as §2.4's gate 3. A failed sync keeps the refs it could not confirm: a failure is core failing to confirm a channel, not learning it is gone, and clearing them would orphan a real channel and have the next pass build a second one beside it.
The pass is requested, not awaited, by the Team reconciler — it makes Discord calls, and a roster sync must never be slowed, failed or held open by an integration hanging off it. It has its own 30-second debounce.
Part 8 — Keeping the integration layer platform-agnostic
8.1 What a Matrix integration would actually need
Researched rather than assumed, and the answer is that Matrix's model diverges from Discord's on every axis this design touches:
| Discord | Matrix |
|---|---|
| Guild → channels; per-channel permission overwrites (~100/channel) | Rooms, optionally grouped in a Space. Access is room membership (invite/knock/join rules) plus m.room.power_levels — an integer map, not per-resource ACLs. No overwrite concept. |
| Roles (250/guild), assignable, mentionable | No role object. The nearest analogue is "membership of a room/Space", or a power level. |
| Voice channel as a first-class persistent resource | No voice channel exists. Real-time voice is MatrixRTC — a session held inside an ordinary room, surfaced by Element Call. Self-hosting it needs infrastructure the homeserver does not ship: a LiveKit SFU plus lk-jwt-service. "Provision a voice channel" maps to "create a room and hope the operator deployed an SFU". |
| Slash commands: a registered application command, an interaction API, a 3s ack, ephemeral replies, follow-ups | None of it. A Matrix bot reads message bodies (or runs as an Application Service acting as virtual users). Commands are a convention in message text, with no registration, no ack deadline, no ephemerality. |
Identity: OAuth2 link → a stable user_identities row, already built |
@user:server MXIDs come from the homeserver. There is no equivalent OAuth link flow unless the homeserver speaks OIDC; otherwise binding is a challenge-DM. |
8.2 Conclusion: capability-based contract, not a shared interface
A shared ExternalChatIntegration would have to name roughly five methods — deliverNotification,
registerCommands, dispatchInteraction, provisionVoice, syncAccess. Matrix implements
deliverNotification cleanly, syncAccess differently (invite/kick rather than ACL edits), and
cannot honestly implement registerCommands, dispatchInteraction or provisionVoice. Three of
five would be no-ops or lies, and the two that survive would be pushed through argument shapes designed
for Discord's resources.
So: capabilities, declared per integration, and core's UI and reconciler read the declaration.
integrations.register('discord', {
capabilities: ['notify.deliver', 'command.dispatch', 'identity.link', 'voice.provision', 'access.sync'],
… })
| Capability | Core exposes | Discord | (hypothetical) Matrix |
|---|---|---|---|
notify.deliver |
the Team notification event + a rendered payload | ✅ channel message | ✅ room message |
identity.link |
user_identities lookup by provider |
✅ (built) | ✅ if the homeserver speaks OIDC |
access.sync |
the path-4 eligible set for a Team | ✅ overwrites/role | ✅ room invite/kick — different mechanism, same question |
command.dispatch |
command definitions + the actor-resolved dispatcher | ✅ | ❌ not declared |
voice.provision |
"this Team qualifies / no longer qualifies" | ✅ | ❌ not declared |
Core's surface is the capability, not the mechanism. For access.sync core says "these user ids
are eligible for Team 3" and never "set these overwrites" — Discord turns that into overwrites (or a
role), Matrix would turn it into invites. That is the difference between a capability contract and a
thin wrapper around Discord's API, and it is where the abstraction actually earns its keep.
What core does with an undeclared capability: nothing at all, silently, and the corresponding UI is
absent. Admin → Teams renders the voice panel only when the enabled integration declares
voice.provision. No stubs, no "not supported on this platform" placeholders for a platform nobody has
installed.
No Matrix implementation is built. §8 is research to shape the Discord contract, exactly as the brief asks.
And no capability registry is built either (2026-08-19). Phase 10 would have extracted the one above from Phases 7–9's Discord code; it is cancelled and deferred until a second integration is wanted. Everything in §8 stays as it is — the comparison is what makes the shape of the Discord work defensible, and it did its job by keeping core's calls phrased as eligibility questions rather than as Discord operations. What is not there is the indirection:
discordis named directly in the bridge, the voice provisioner and the command dispatcher, and a second platform is a phase, not a configuration change.
Part 9 — The explicit answers
1. Is a full threaded forum one phase, or should Part 5 split?
Split — into two, by layer rather than by feature. 5a is the access-control model (grants, the
four-path resolver, audit, leader/staff UI) plus announcement threads; 5b is discussion threads,
replies, editing and the moderation ledger. The full schema — including threads.type and the
moderation table — lands in 5a, so 5b enables code paths and never migrates data. This split is
chosen over "announcements first, generalise later" because an announcement is a degenerate thread, and
building it as its own thing then replacing it wastes the work. See §5.1.
2. Does the notifications bridge need new ntfy topic/routing work?
No ntfy work at all; a small amount of website work. ntfy topics are per-device UnifiedPush
endpoints, not per-subject channels, and the payload is a content-free tickle — so Team scoping is a
recipient-set problem inside the website, not a topic problem in the relay. What is missing is a
"these N users" fan-out: today pushDispatch.publish does all-subscribers or one ownerUserId. The
gap closes with one new query (endpointsForUsersStream) and one new signature
(publishToUsers), four fixed stream ids in coreStreams.js, and one small opt-out table for per-Team
mute. No new topics, no ntfy ACLs, no per-user accounts, no second pipeline. See §6.
3. Voice channel: straight delete below threshold, or archive first?
Delete — but only after a 7-day grace window, and never while the sync is stale. A voice channel
holds no message history, so the archive-don't-delete caution from the earlier text-channel proposal
does not transfer: deletion destroys nothing recoverable. What does apply is churn — a Team
oscillating around the threshold would delete-and-recreate, changing the channel id, breaking pinned
links and spamming the audit log, all with zero content loss. A grace window fixes exactly that and an
archive would not. The stale-sync guard is separate and stricter: if team_sync_state is stale, the
reconciler makes no integration decisions at all, so a channel is never removed because a sidecar
was down. See §7.3.
4. Shared ExternalChatIntegration interface, or a capability contract?
Capability contract. Matrix has no channel-with-overwrites, no role object, no voice channel at
all (voice is a MatrixRTC session inside an ordinary room, needing a LiveKit SFU the homeserver does
not ship), and no slash-command registration — a Matrix bot parses message text with no interaction
API, no 3-second ack and no ephemeral replies. Of the five methods a shared interface would name, a
Matrix implementation could honestly provide two. Forcing the other three would mean no-ops on one side
and Discord-shaped arguments on both. Core therefore exposes capabilities — notify.deliver,
identity.link, access.sync, command.dispatch, voice.provision — stated as questions
("these users are eligible for Team 3") rather than mechanisms ("set these overwrites"); each platform
implements what it can and declares the rest absent, and core renders UI only for declared
capabilities. See §8.
5. What if none of a Team's leaders has a linked Discord account? Nothing breaks, because no integration action is leader-scoped. The two classes of leader action answer differently:
- Platform-side leader actions — grant/revoke forum access, post an announcement, pin or lock a thread — depend on path 2 (leadership, from the module) and path 3 (forum access, core's). Discord is irrelevant to all of them, and they keep working unchanged.
- Integration-side actions are performed by the bot with its own permissions, on a decision core made from the Team's linked-member count. No design here asks a leader to authorise, own or execute a Discord action, and none should be added — a leader-scoped Discord action would create exactly this single point of failure.
So a Team with zero Discord-linked leaders is not a degraded Team. Its leaders simply do not personally
get voice access, exactly like any other unlinked member (§2.6 hop 3). Three consequences worth naming:
/team run by an unlinked caller returns the public projection plus an ephemeral prompt to link;
staff can always act on any Team from the site, which is the standing escape hatch; and the
condition is surfaced, not silent — the Team's admin page shows "0 of 3 leaders have linked
Discord" as an informational row, because otherwise it looks like a bug the first time someone notices
it.
6. Why the admin-approval gate covers three actions and not every staff action. Asked and answered during review (2026-08-17): the gate exists because untrusted game-sourced data becomes a public page, not because staff actions are inherently risky. A guild name is written by a player, unreviewed, and this design turns it into a page, a URL, a Discord message and a voice-channel name — so the actions that publish such a string (clearing a reserved-name hide, setting a display name, un-hiding) need a second pair of eyes, and every other staff action does not.
The alternative — approval on all staff actions site-wide — was considered and rejected as a different
workstream: it would touch nearly every admin controller (moderation, appeals, roles, bans, wiki, news,
modules, settings) and deserves its own design doc rather than riding in on Teams. team_moderation_requests
carries an action enum and a payload, so extending it is adding a value, but no action should be
added without asking the same question: does this publish untrusted game data?
The gate is moderator-initiated only, with admins applying immediately. Four-eyes on admin actions
was rejected for a concrete reason: users.role defaults to admin, npm run seed creates exactly
one, and most deployments have precisely one admin — a second-admin requirement would wedge them
completely.
Part 10 — Ownership: what is contract, what is core-internal, and what is the module's
Read this part before building anything. Everything above describes a system that spans a versioned contract, a large body of core-internal code, and a module — and the three are easy to confuse because they all appear in the same paragraphs. This part draws the lines; §10.5 is the one to check a specific question against.
This document is not normative. MODULE_API.md is the contract. Where the two
ever disagree, MODULE_API.md wins and this file is wrong. Part 11 proposes what to add there; until
that lands, nothing in §10.2 exists.
10.1 Three ownership classes, and one that is a different contract entirely
| Class | Who owns it | May a module rely on it? | What breaking it costs |
|---|---|---|---|
| A. Module API contract | core, published in MODULE_API.md |
yes — this is the entire list | a MODULE_API_VERSION major bump |
| B. Core-internal | core | no. Never. | nothing — core may change it freely |
| C. Module-owned | the module | it is the module's | the module's own version |
| D. Wire protocol | the shard↔sidecar boundary | different contract, different number | a PROTOCOL_VERSION bump |
Class B is the one that causes trouble here, because Teams are a core feature and "core owns
it" reads like "it is available". It is not. MODULE_API.md §1.2 is explicit that core's file layout,
table names, middleware ordering and components are not contract. Every Team table in this
document is class B: core-owned, and out of bounds to a module even though the module is what
populates them. A module reaches Team state only through the class A members in §10.2 — and mostly
it does not need to, because the module is the one being asked, not the one asking.
Class D is a genuinely separate contract with its own version number. MODULE_API.md §1.1 says so
directly: PROTOCOL_VERSION "versions the shard wire and has nothing to say about a website module."
Phase 1 bumps D; Phase 2 bumps A. They are independent, they land in different repos, and neither
implies the other.
10.2 Class A — the contract surface this design adds
Everything here is proposed for MODULE_API.md in Part 11. Nothing else in this document is contract.
| Member | Shape | Defined in |
|---|---|---|
api.registerTeamProvider(provider) |
{ getTeams, getTeamMembers, getTeamLeaders } |
§2.3 |
| — the envelope every provider method returns | { ok, complete?, … } / { ok: false, reason } |
§2.3 |
| — the team shape | { externalId, name, abbr, meta } |
§2.3 |
| — the member shape | { memberKey, displayName, rankLabel, leader, online, userId } |
§2.3 |
ctx.teams.publish(event) |
the six event kinds and their payloads | §2.3 |
ctx.teams.reconcile({ reason }) |
debounced; returns immediately | §2.4 |
ctx.teams.activity.push(items) |
{ externalId, kind, summary, occurredAt, visibility, actorMemberKey?, payload?, dedupeKey? } |
§4.1 |
api.registerSlashCommands(commands) |
{ name, description, options, access, handler } |
§7.1 |
| — the option schema | string | integer | boolean | user, required, choices |
§7.1.1 |
| — the actor handed to a handler | { platform, platformUserId, guildId, userId, isLinked, isStaff } |
§7.1 |
| — the response envelope a handler returns | { text?, embed?, fields?, url?, ephemeral? } |
§7.1 |
client slot team.overview |
props { teamId, externalId, moduleId } |
§3.4 |
client slot team.member.row |
props { memberKey, userId, displayName } |
§3.4 |
The provider is core calling the module, which is new for this contract. Every existing
registration is either the module claiming a mount (registerRoutes) or core notifying it
(registerPostHook). registerTeamProvider is core asking a question and waiting for an answer —
the same direction registerAnnounceLeg's dispatch already goes, which is why it is modelled on it
rather than invented. The consequences (a 10s budget, the envelope, failure being staleness rather
than emptiness) are all in §2.3–2.4 and all of them are contract.
10.3 Class B — core-internal, and off limits
Core owns and may change all of this without a bump. A module must not read, write, require or
name any of it. MODULE_API.md §5.1's CI grep (no relative path escaping the module root) catches
the import case; the table case is caught by §2.6's namespace rule and by review.
- Every table in this document.
teams,team_members,team_sync_state,team_leader_overrides,team_forum_threads,team_forum_posts,team_forum_moderation,team_forum_grants,team_forum_uploads,team_activity,team_integrations,team_integration_config,team_notification_prefs,team_moderation_requests,content_reports. Note these carry no<moduleId>_prefix — correctly: §2.6's prefix rule binds modules, and these are core's. - The reconciler and every refusal gate, the quarantine, the backoff, the debounce (§2.4).
- The four-path resolver —
forumAccess(),externalEligible(), the leadership override application (§2.5). A module never answers an access question and never asks one. utils/reservedNames.js, the auto-hide, the review queue, the approval gate (§2.8–2.9).- The forum sanitizer profile and the image renderer (§5.5.3). The module has no say in whether an image renders.
pushDispatch.publishToUsers,endpointsForUsersStream, the recipient computation, the email sink (§6). A module publishes through the existingctx.push.publish; the Team fan-out is core's and is not exposed.- Every settings key —
teams_forums_enabled,teams_forum_images,teams_forum_uploads_ack,teams_reserved_terms,teams_reconcile_interval_s,teams_max_grants_per_team,voice_*.ctx.settingsis three functions over arbitrary keys (MODULE_API.md§2.3) and a module reading core's policy keys is out of contract even though the call would succeed. - The bot↔app internal API —
/internal/commands,/internal/commands/dispatch,/internal/team-notify. Not contract in any versioned sense:appandbotship from one repo and release together, and the shared secret means only they can call it. A module never sees it; it registers a command and core does the rest.
10.4 Class B′ — core-internal to a module, but a public API surface
A separate thing that is easy to conflate with class A: the REST routes and stream ids in this
document are not module contract, but they are an external surface with its own process
obligations — OpenAPI annotations, npm run swagger, the routes:manifest --check zero-line diff,
and a BACKEND_DESIGN.md edit (CLAUDE.md's standing rule).
- Every route in §2.11, §5.4, §5.6 and §6.3 — consumed by the SPA and, eventually, the app.
- The four
team.*stream ids in §6.2. These are core catalog entries, not a module's, so §2.4's<moduleId>.<name>namespacing does not apply — the same way core's existingnews.postdoes not carry one. They are consumed by the Android app, which makes renaming one a client break, so treat them as frozen once shipped.
10.5 Class C — the module's, and core never reaches in
If a question below has a game-specific answer, it is the module's and core must not acquire an opinion about it. This list is the practical test for "did we just put game semantics in core?"
| The module owns | Why core cannot |
|---|---|
What externalId is |
only the game knows what identity survives a rename (§2.2) |
What memberKey is |
a character serial, an account, something else entirely |
| Rename vs. genuinely-different-team detection | core sees only "an id appeared / a name changed" |
Resolving userId from a game account |
shard_account_links is module-owned; core reading it by name is core naming a module's table (§2.3) |
| Whether a member is a leader | game rank semantics — for UO, GuildRank.Rank >= 4 |
rankLabel, meta, and everything in them |
opaque strings and JSON; core stores and displays, never branches |
| Online status, and its freshness | derived from the module's own live state (§3.3) |
| Roster field projection per audience rung | the visibility framework and its config are module-owned (§3.3); core hands over rows and a viewer and takes back what the module permits |
Activity kind vocabulary |
§4.1 — core stores kind as an opaque string |
Activity summary text |
core cannot compose "gained 15,000 gold" for a game it knows nothing about |
Activity visibility |
the module knows which of its own events are public-safe; core enforces the answer |
Anything rendered into team.overview / team.member.row |
slots are named for a place, never a meaning (§3.4) |
Ingesting guild.roster / guild.leave |
class D data; core never sees a shard event |
The failure this list prevents is the one MODULE_SYSTEM.md Phase 3 existed to undo: core
acquiring a UO-shaped opinion. Concretely, if core ever needs to parse a kind, compose a
summary, or decide what a rank means, the design has gone wrong — and scripts/checkModuleIdentifiers.js
(MODULE_API.md §5.2) will fail the build the moment one of those opinions is spelled with a UO word.
10.6 Class D — the wire protocol, on its own number
Phase 1 only. Governed by ../link/PROTOCOL_2.md and
../link/v3.md, not by MODULE_API.md.
| Artifact | Repo | Note |
|---|---|---|
guild.roster, guild.leave event kinds |
servuo-plugins (emit), link (persist), module-uo (ingest) |
new kinds; guild.update unchanged |
PROTOCOL_VERSION 3 → 4 |
link/sidecar/src/main.rs:49 |
every response carries X-UOLink-Version; a mismatch is 409 |
protocol = 4 |
servuo-plugins/overlay.toml:27 |
same PR as the emitters — CI copies it into the release manifest and the installer refuses a mismatched pair |
members on the sidecar guilds board + GET /guilds |
link |
the snapshot rule, so a fresh website gets a roster without waiting for a change |
A module does not participate in class D at all. MODULE_API.md §2.7 forbids a module opening a
connection to a game server; the module reads the sidecar, which is the only thing that ever speaks
this protocol.
Part 11 — MODULE_API_VERSION bump proposal
Amended 2026-08-17, on the org lead's decision. The seven additions below land under one 1.6.0, declared in phase 2, rather than a minor bump per phase.
MODULE_API.mdtherefore documents members before they work, so each is marked with the phase that implements it, and the two that do not yet —ctx.teams.activity.push(§4, phase 3) andapi.registerSlashCommands(§7.1, phase 7) — are present and throw with an error naming that phase. Present rather than absent so a module written against the published version fails at registration with an explanation, instead of at whatever moment someone first exercises the feature.
1.6.0 — minor. Every change is an addition; no member is removed and no existing signature changes,
so MODULE_API.md §1.1's table gives minor, and module-uo's coreApi: "^1.3.0" still resolves.
| Addition | Half | Section |
|---|---|---|
ctx.teams.publish(event) — six Team events |
server | §2.3 |
ctx.teams.reconcile({ reason }) |
server | §2.4 |
ctx.teams.activity.push(items) |
server | §4.1 |
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) |
server | §2.3 |
api.registerSlashCommands([...]) |
server | §7.1 |
Core declares slots team.overview, team.member.row |
client | §3.4 |
capabilities may include teams (opaque, as always) |
manifest | — |
Two things this bump does not do, deliberately:
- It does not add a
getTeamRostertoctx. A module reads its own data; core does not offer to read it back. - It does not let a module write
team_members,team_forum_grantsor any Team table. The module answers questions; core owns the storage.MODULE_API.md§2.6's table-prefix rule already forbids a module touching a core table, and this is the same boundary stated for the new tables.
Also required, separately: PROTOCOL_VERSION 3 → 4 (link/sidecar/src/main.rs:49, mirrored in
servuo-plugins/overlay.toml:27, bumped in the same PR as the emitters so the next bundle
composes). This is a different number with a different job (MODULE_API.md §1.1) and it is what
makes §0.1's roster possible:
guild.roster— new kind:{ id, members: [ { serial, name, rank, leader, online, acct?, webId? } ] }. Emitted when the member set changes (the sweep already computes a member-serial sum; it holds the actual set instead and diffs it) and on the reconnect baseline. Per §0.7 decision 3,acct/webIdare present only for members whose game account is linked.guild.leave— new kind, the missing counterpart to the existingguild.join, now computable from that same set diff.guild.update— unchanged, so nothing that reads it today has to change.- Sidecar:
membersJSON on the existingguildsboard table, served fromGET /guilds— the §12.2 snapshot rule, so a website that connects fresh gets a roster without waiting for the next membership change. - Multiple leaders on the wire come from
PlayerMobile.GuildRank.Rank >= 4(Scripts/Misc/Guild.cs:38), which ServUO already maintains per member.
Part 12 — Phased plan
Every phase is independently shippable and leaves the site working. Phases 1 and 2 are the only hard serial dependency in the list.
Phase 10 is cancelled (org lead, 2026-08-19), deferred until a second integration is wanted or it is asked for by name — see its own entry. Phase 11 is therefore the last phase of the bet.
Phase 11 is the exception to "independently shippable", and it is last on purpose (org lead,
2026-08-18). The integration kit teaches an outside audience to build against this contract; Teams
expands the contract, so the book is the last thing owed before edge becomes main. It is also the
only phase that cannot merge until the cutover exists — see its own note.
Phase 0 — a one-guild roster spike (servuo-plugins + link, throwaway)
Not a deliverable — insurance on the phase that gates everything else. servuo-plugins has no CI
build: the plugin compiles only inside ServUO, and the dynamic rebuild can silently reload a stale
Scripts.dll, so "it booted clean" is not evidence the new code is live. Phase 1 is a four-repo
protocol bump with that verification story, and discovering a problem at the end of it is expensive.
Emit guild.roster for a single guild against the local ServUO tree
(C:\Users\colby\Desktop\ServUO) and the real Rust sidecar — not a stub — confirm it lands in the
store, survives a sidecar restart, and comes back out of GET /guilds. Then build Phase 1. Days, not
weeks, and it retires the only unknown in the plan.
Phase 1 — the roster on the wire (servuo-plugins + link + module-uo + installer + docs)
Amended 2026-08-17, after Phase 0 and while building this. Two corrections to what follows.
The sidecar had no schema-migration mechanism, and this phase is the first change that needs one.
store.rs'sSCHEMAisCREATE TABLE IF NOT EXISTS, which can add a table but cannot add a column to one that already exists — and every schema change up to Protocol 3.0 happened to add whole tables, soALTER TABLEappears nowhere inlink's history and the gap was invisible untilguilds.members. Settled by the org lead:PRAGMA user_versionstepped migrations, each step transactional with the bump recording it; a failure aborts startup (already the behaviour, and safe because the shard dials out), while a database from a newer sidecar warns and continues so a binary rollback stays a recovery path. Notsqlx::migrate!, whose per-file checksums hard-fail startup if a released migration is ever edited.
installerjoins the phase, which is why the heading names five repos rather than four.backup.rsjustifies not copying the sidecar database on two claims: that every table isIF NOT EXISTS(which the migration above falsifies) and that the sweeps repopulate everything (already false —eventsis never pruned and the website backfills fromGET /historyon every reconnect). The behaviour is unchanged and correct; only its stated reason needed fixing, and a wrong reason left in place is what lets someone extend it to a case it never covered.The spec for all of it is
../link/v4.md.
The prerequisite for everything. Nothing in Team core can be built against counts.
BridgeSocial.cs holds the member serial set rather than its sum and diffs it → guild.roster
and guild.leave; overlay.toml protocol → 4. Sidecar: members on the guilds board,
PROTOCOL_VERSION → 4, GET /guilds projection. module-uo: ingest both kinds, a
shard_guild_members table, the kind→feature map entry and field projection for the new fields.
A new docs/link/v4.md as the spec of record, plus PROTOCOL_2.md §10.1 (which sketched this design
in 2.0 and had it half-built) and INTEGRATION.md.
Ships: a richer public Guilds page (real rosters) on its own merit, with no Team code anywhere.
Verify: the five-rung shard visibility walk against a live ServUO + sidecar, confirming acct/webId
never reach a caller below their rung.
Stagger the reconnect baseline. BridgeLink.OnConnected clears the diff cache, so every guild
re-emits on reconnect. Checked rather than assumed: the outbound queue cap is 10,000 lines
(BridgeConfig.QueueCap) and 200 guilds is 200 lines, so there is no drop risk — but each line is
now fat (a 200-member roster ≈ 16 KB) and the sidecar's read_line has no length bound. Spread the
baseline re-emit across several ticks rather than firing it in one, and cap members per guild.roster
line with a continuation flag for the pathological guild.
Phase 2 — Team core (website + module-uo + docs)
Amended 2026-08-17, while building this. Five corrections, all found by building or testing the thing described below.
§2.5's SQL and §2.10's decision cannot both hold as written. §2.5 gives
team_forum_grantsa generated columnactive_user AS (IF(revoked_at IS NULL, user_id, NULL))and aCASCADEforeign key; §2.10 later settles that key asSET NULLso the audit trail survives an account deletion. MariaDB refusesON DELETE SET NULLon a foreign key whose column is a base column of a STORED generated column (error 1901), so the generated column forces theCASCADE— and with it, the loss §2.10 exists to prevent. Settled: §2.10 wins. The marker is derived fromrevoked_atalone anduser_idmoves into the unique KEY, which gives identical semantics — at most one active grant per (team, user), unlimited revoked rows — withuser_idfree to beSET NULL.
team_forum_grantsis created in this phase, not in phase 4, so the four-path resolver is written once and its non-contamination tests are real. Nothing writes it yet; the grant flow, the per-Team cap and the leader UI stay phase 4's.Two columns on
teamsthat this document did not contemplate, both serving §2.4's gates.roster_synced_at:team_sync_stateholds one row per module, and gate 3 leaves one Team's roster untouched while the others sync — without a per-Team stamp that Team's page would report the module's last success as its own, which is exactly the staleness the gate exists to surface.members_empty_since: gate 4's per-Team quarantine, the twin ofpending_empty_since.
leaderon the member shape is not path 2. §2.3 putsleaderon a member and §2.5 says the sync writesis_leaderfromgetTeamLeaders(); taking both literally gives one column two writers, and the roster's write lands first — so a refusedgetTeamLeaders()silently demoted everyone. The roster now seedsis_leaderon insert only, so a Team is not leaderless while that call is failing, andgetTeamLeaders()alone moves it afterwards.The §2.8.2 matcher needed two narrow widenings, both real impersonation vectors the whole-word rule missed: a single-word term also matches a name word's singular ("Guild of Moderators"), and a run of two or more single-letter words is also compared joined ("G.M."). Neither re-admits substring matching — only a trailing
soff the whole term is stripped, and the join is of single letters, never of the whole name.
teams, team_members, team_sync_state; registerTeamProvider + the three ctx.teams members
(MODULE_API_VERSION → 1.6.0); the reconciler with all four refusal gates; the four-path
resolver with its non-contamination tests; team_leader_overrides; the public/player/admin read API;
Admin → Teams (sync state, resync, archive, overrides). module-uo implements the provider over
Phase 1's data.
Plus the impersonation controls, which belong here because this is the phase where game-sourced names
first become platform entities: utils/reservedNames.js, auto-hide with the review queue (§2.8), the
hidden / display_name_override columns, team_moderation_requests and the admin-approval gate on
the three publishing actions (§2.9), and the §2.10 account-deletion FK decisions — settled now, while
the tables are being created, rather than migrated later.
Ships: Teams exist, are visible in the admin panel, stay correct across a sidecar outage, and a guild called "Admin" cannot put an official-looking page on the site.
Acceptance, three:
- Kill the sidecar mid-reconcile — zero rows change, state goes stale, the UI says so.
- A guild named for a reserved term (a role name, the deployment's brand, or "Runic Gateway") is created, auto-hidden, absent from every public surface, fully working for its own members, and listed in the review queue with the matched term.
- A moderator un-hiding it produces a
pendingrequest and no public change; an admin approving it publishes; an admin doing it directly publishes at once. All three land inactivity_log.
Phase 3 — Team pages, roster, nav, activity feed (website + module-uo)
Amended 2026-08-17, while building this. Six corrections. The first is the org lead's, and it changes what this phase ships; the rest were found by building the thing described below.
THERE IS NO CORE TEAM SURFACE. Teams is a contract primitive, not a page. §3.1 puts
/teams,/teams/:slug,/teams/:slug/rosterand/player/teamsin core and §3.5 registers three core nav entries for them. Settled (org lead): all seven are dropped. Core does not own the word for a Team — a UO shard calls them guilds, and the Rust module that comes next will call them clans — so a core page under a noun core invented would sit besidemodule-uo's existing/uo/guildssaying the same thing twice, in the wrong vocabulary. Core keeps the tables, the sync, the access resolver, the activity feed and the whole API; the module builds the pages on that contract./admin/teamsstays: an operator inspecting the primitive is looking at the primitive.So the extension slots invert, and that is a new
MODULE_API§3.7 direction.team.overviewandteam.member.rowassumed core rendered the page. They are replaced byregistry.declareModuleSlot(id, name, { core }): a module declares a place on its own page, namespaced under its own id, and names which of core's contributions belongs there.module-uodeclaresuo.guild.detailand asks forteam.activity; core offers the activity feed, because only core can resolve whether a viewer is inside the Team and the public/members split is a security boundary. Core names the contribution, never the slot — amended in phase 11, inside 1.6.0, after the integration kit found that the literal-name version worked for one module and silently did nothing for any other. Core's contributions are applied at mount rather than eagerly — core's bundle evaluates before every module chunk, so at the moment core offers one, no module-declared slot exists yet.Slotjoins the shared UI kit as its ninth member so the module renders the place with core's own error boundary.A module names a Team in its own vocabulary, so
GET /public/teams/by-external/:moduleId/:externalIdis added: core's row id and slug are core-internal and handing them to a module is how a module ends up storing them. The module id is matched rather than trusted — an external id is unique only within a module.§3.3's projection is an EIGHTH
MODULE_APImember and 1.6.0's list said seven. Settled by the org lead: 1.6.0 is amended in place rather than bumped, applying the same rule Protocol 4 got in phase 2 — a contract owes a bump only once it has landed onmain, and 1.6.0 is onedgeonly."The module declines" needed splitting in two before it could be implemented. §3.3 says a module that declines yields the public projection, fail closed. But no module at all and a module whose rung system could not be consulted are opposite situations: the first is withholding nothing and must serve the roster whole, the second must serve none of it. The refusal therefore carries
projects—falsefor "there is no audience model here",truefor "there is one and I could not ask it" — and only the second fails closed. Also, the module answers with member keys, not rows: returning rows would let a module widen what is published by handing back auserIdcore had withheld, leaving core's field guarantee resting on every module's good behaviour.Core's five activity kinds are four here.
core.forum.threadhas nothing to emit it until the forum lands in phase 4. Separately, and not in the doc at all: the first roster for a Team emits no join items. Importing a 155-member guild is one Team arriving, not 155 people joining, and emitting a join per member would bury every real event under the import and reach the row cap on day one.roster_synced_at IS NULLis the condition, which covers both a new Team and a newly installed module adopting an existing one.§2.11's route table has no activity endpoint though §4.3 describes a feed filtered by the viewer's access. Added on the org lead's decision:
GET /api/v1/public/teams/:slug/activity, paged, with the visibility resolved from the session and never from a parameter. It is the first public route whose content depends on identity, which needed a newoptionalAuthmiddleware —attachSessiononly decodes a token, so a banned or logged-out account would have kept reading the members-only half until its JWT expired.
/teams, /teams/:slug, /teams/:slug/roster, /player/teams; the linked/unlinked/guest surface;
team.overview + team.member.row slots; nav registration; team_activity +
ctx.teams.activity.push + core's own five activity kinds + the retention prune.
Ships: the whole public Team experience. Independently valuable with no forum and no Discord.
Phase 4 — Forum 5a: access model + announcements + admin controls (website + module-uo)
Amended 2026-08-18, while building this. Six corrections. The first is structural and follows from phase 3; the rest were found by building the thing described below.
The forum had nowhere to live, and §5.4's route table did not notice. §3.1 gave it
/teams/:slug/forum/*— a CORE page — and phase 3 deleted every core Team page. The routes are unaffected (they are all/playerand/admin), but the participant SURFACE had no home. Settled by the org lead the same way phase 3 settled the activity feed:module-uodeclares a second place on its guild page,uo.guild.forum, and core fills it. So this phase spans two repos, not the one named above.Two slots rather than one, because a slot holds one component and the first fill wins. Stacking the feed and the forum into a single fill would take from the module the ability to place core's two contributions separately on its own page, which is the whole point of the module owning it.
The forum panel navigates by SEARCH PARAM (
?thread=12), not by route. A thread has to be linkable and core cannot mount a route for one — the route belongs to the module's page. A search param gives a shareable URL under whatever path the module chose, with no core route anywhere in it. It is why the fill is one component holding both a list view and a detail view.
relhad to be added to the forum sanitiser's allowed attributes to make links SAFER, not laxer. The profile writesrel="noopener noreferrer nofollow"through a transform, and sanitize-html strips any attribute not in the allowlist — including one its own transform just added. Without the entry every forum link shipped withoutnoopener, silently.The bare-URL linkifier is a second pass, and its ordering is the security property. §5.5.3 says an author writes a URL and core renders the picture, which requires the URL to have become an anchor on the way in. Linkifying runs AFTER sanitising, over the sanitiser's own output and only on text outside tags: every text node is HTML-escaped by then, so the matched URL is safe in both the href and the link text. Running it first would be an injection point.
The upload sweep runs whether or not
uploadsis the current mode, which is not obvious and is the point. An operator who turns uploads off after a problem still has the files; a sweep that switched itself off with the setting would strand exactly the bytes they were trying to be rid of — and it is the mechanism behind the dialog's promise that disabling does not delete.
team_forum_grants, the grant/revoke flow with audit into activity_log, leader vs staff authority,
the full forum schema, announcement threads, and the leader/staff grant UI.
Plus the operator's controls (§5.5), which land here because a forum without an off switch is one an
operator cannot ship: teams_forums_enabled and its 404 guard; teams_forum_images and the
renderer-owned image path (the forum's own sanitizer profile with img excluded in every mode,
URL detection, the https + extension rules); uploads mode with magic-byte sniffing, quotas, rate
limits, team_forum_uploads attribution and the deletion sweep; and the versioned, server-enforced,
activity_log-recorded upload acknowledgement.
Ships: the permission model everything downstream depends on, plus the switches an operator needs to run it, at low surface area.
Acceptance, four:
- A granted, unlinked user reads the forum, appears under "Forum guests", and is absent from every
membership count and from
externalEligible. teams_forums_enabled='0'→ every forum route 404s, and no thread, post, grant or subscription is touched; flipping it back restores the forum unchanged.- A post containing an image URL renders as a plain link under
disabled, as a link plus an embed underremote— with no change to the stored HTML between the two, which is the property the renderer-owned design exists to give. PUT teams_forum_images='uploads'without a matchingacknowledgeversion is rejected400server-side, with the admin UI checkbox bypassed.
Phase 5 — Forum 5b: discussion + moderation + reports (website)
Amended 2026-08-18, while building this. Five notes. The first is the org lead's decision; the rest were found by building the thing described below, or on the live rig afterwards.
Reports are site administration only. §5.6's "a leader may also see and act on reports for their own Team" is decided against, not deferred — see the amendment there. It is the phase's most important property and it is a NEGATIVE one, so it is asserted directly in the test suite rather than left to be noticed: the report model's whole function surface is pinned, and
queue/handleare checked not to mention leadership at all.The edit window is an admin setting, not a constant (§5.5.7), and it is evaluated on the server twice — once as advice on the read path, once as enforcement on the write. That is the phase's other structural rule: a time-bounded permission must not take its clock from the party it bounds.
§5.6's unique key does not work as written, and the shipped table uses a generated
open_markerinstead. See the amendment there; it is the one place in this document where the SQL and the prose beside it disagreed.This phase spans ONE repo, which is worth saying because phase 4 did not. Phase 4 needed
module-uobecause the forum had no surface after phase 3 and a slot had to be declared. Phase 5 grows the component that fills that slot, souo.guild.forumis untouched and nothing in the module changes.The live rig found one defect, and it was a message rather than a behaviour. The post-moderation route's validator listed only the four actions a post accepts, so
pinreturned a generic "Validation failed" instead of the sentence written for it — leaving that branch reachable only from its own unit test. Walking the surface for real is what turns "documented, tested and unreachable" into something anyone notices.
Discussion threads, replies, the edit window, pin/lock/hide/delete, team_forum_moderation, the admin
ledger view, and abuse reporting (§5.6): content_reports, the report control, and the queue in
the existing admin moderation section.
Reports land here rather than in Phase 4 only because discussion is what generates them at volume — if
Phase 4 ships uploads mode enabled anywhere before Phase 5, pull reports forward into Phase 4.
An upload path with a liability acknowledgement and no way for a member to raise a problem is the one
combination this plan should not ship. (In the event, phase 4 shipped uploads mode with the default
off, so nothing was pulled forward.)
Also lands here, because both had existed since phase 4 with nothing rendering them: the per-Team
forum moderation ledger on the admin Teams screen — the actor_role column that keeps a leader's
housekeeping distinguishable from a staff intervention was readable only from a DB client — and
softDeleteUploadsForPost, which post deletion is the first caller of and which needed an inverse so
delete → restore does not return a post's words while silently losing its pictures a retention
window later.
Acceptance, four:
- A member opens a discussion and a granted non-member replies to it; the same member is refused an
announcement
403while a leader is allowed one. - A locked thread refuses replies at
409from every identity including staff, and unlock → reply → relock leaves three rows in the Team's ledger saying so. - An author edits their own post inside the window and is refused
403outside it; staff edit the same post at any time, and a staff edit of somebody else's post writesactivity_logwhile a member's own edit does not. - A member reports a post; the report reaches
/admin/moderation/reportsand answers403to the Team's own leader, to the reporting member and to every other participant; handling it changes the report's status and nothing at all about the content.
Phase 6 — Team notifications (website + module-uo) — DONE 2026-08-18
Four core streams, publishToUsers + endpointsForUsersStream, the recipient computation,
team_notification_prefs, its settings screen, and email as the third sink (§6.4) with digest
mode and one-click unsubscribe.
TWO repos, not the plan's one. module-uo joined for two lines it alone can supply: a third
declared slot (uo.guild.header, for the mute toggle) and pageUrlTemplate on its team provider,
without which core cannot write a link to a Team page at all — see the four amendments at the head of
Part 6.
Android is deliberately not in this phase — see the deferred note in
../android/PLAN.md. The streams exist in the catalog and the app will show
them as toggles automatically, but nothing here builds a Team screen or a deep-link target for the
app, so a Team tickle on mobile opens the app and no more. That is a stated limitation, not an
oversight.
Phase 7 — Discord: slash commands (website + module-uo + docs) — DONE 2026-08-18
api.registerSlashCommands, /internal/commands + /internal/commands/dispatch, the bot's
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
through it.
Ships: a working /guild, and the seam a module needs for its own commands.
THREE repos, not the plan's website + bot + docs — bot is not a repo. It is a workspace
inside website, so the bot half lands in the same PR as the server half; module-uo joins instead,
because the command that proves the seam belongs to the module and not to core (see the amendment at
the head of §7.1).
Walked on the live rig before the PRs opened — real ServUO + real sidecar (protocol 4) + the app
with module-uo installed, with the bot's own pull/execute path driven against it and a fake standing
in for Discord. It proved the audience rung holding over the chat surface (guilds gated to staff:
anonymous and linked-player refused, linked admin served, same command), the Discord provider
resolving by kind on a deployment whose provider slug is my-discord, a banned account resolving as
unlinked, the disable nudge firing with its reason and degrading to a log line with no bot running,
and the pull emptying plus dispatch answering unknown for a module switched off at runtime.
It found two defects, both folded in. A refusal was posted PUBLICLY — ephemerality is fixed at the
deferral, before the handler has said anything, so the envelope's flag was read and ignored, and "not
shown to your account" announced a member's access level to the channel. And the refusal offered
linking on a shard gated to staff, where linking reaches player and stops.
The bot got its first test harness. It had no test script and no tests at all — CI ran
npm ci --prefix bot and nothing else — which was defensible while the bot only wired up its own
static commands. It is not defensible now that it merges a pulled set into a single all-or-nothing
registration and runs the interaction path, and phases 8 and 9 add more. bot/test/ and a
bot-tests job replace bot-install.
Phase 8 — Discord: notifications bridge (website + docs) — DONE 2026-08-18
team_integration_config, the bridge as a third sink beside push and email, POST /internal/team-notify, and the admin per-event configuration.
Ships: a Team's forum posts, announcements and roster changes arriving in a Discord channel the operator chose, per Team or deployment-wide.
ONE code repo, not the plan's website + bot. bot is a workspace inside website, the same
correction phase 7 made — but unlike phase 7 nothing here belongs to a module, so module-uo is
untouched: the four streams are core's own and the bridge reads core's own forum. MODULE_API_VERSION
does not move.
Walked on the live rig before the PRs opened, per the order phase 5 set.
Five things the tree disagreed with §7.2 about
-
PRIMARY KEY (platform, team_id)cannot hold the default row. MariaDB coerces every primary key column toNOT NULL, soteam_id NULL— the deployment-wide default, and the base case of the whole override mechanism — is unrepresentable. As built: a surrogateid, a generatedteam_key AS (IFNULL(team_id, 0)) STOREDin the unique key, and the foreign key the original DDL had no room for. Same idiom asteams.active_keyandcontent_reports.open_marker. -
The visibility gate has no data source on either side, and cannot have one. §7.2 bridges an event only if "its
visibilityispublic, or its destination channel is configured for a members-only Team context". The fourteam.*streams carry no visibility — onlyteam_activityrows do, and a notification is not an activity row — and forum threads have no public/members column because a forum is members-only by construction, everything in it sitting behindteam_forum_grants. So §7.2's own example config,['team.announcement','team.forum.post'], names exactly the two events that are never public. Nor can core see a Discord channel's permissions to check the other half.As built: an attributed operator acknowledgement,
members_ack/members_ack_by/members_ack_at, in the shapeteams_forum_uploads_ackalready uses. Enabling a members-only event without it is refused 422 rather than dropped at delivery, because a configuration that silently does less than it says is worse than one that will not save. It is re-asked at delivery as well as at the save, so a row that loses the tick stops carrying those events at once — and changing the channel clears it, since an acknowledgement is about a destination and cannot survive the destination changing underneath it. -
"Identical to
announceandmod-reverse" names two different things.announceridesannounce_jobswith backoff, retries and a per-leg retry button;mod-reverseis one-shot. The bridge is one-shot: a news post is a durable artifact whose Discord copy is expected to exist, while a Team notification is the moment it describes, and a message arriving twenty minutes after the conversation moved on is worse than one that never arrives. A bot that is down drops it, which is the deal the push tickle already takes. -
The author exclusion stops at the channel. Push and email both subtract the author; the bridge does not. Excluding is a per-recipient idea and a channel has no per-recipient anything — suppressing the message because the author happens to read that channel would deprive everyone else in it.
-
A roster event has a count and no name. The sync notifies once per run rather than once per member (§6.2), so a count is all the caller holds; it is also all it should say.
memberJoinedgrew an optional{ count }for the bridge only — a channel has no app on the other end to pull anything after a content-free nudge — and the tickle beside it is unchanged.
Where the admin surface lives, and why it is not in the Discord panel
Its own panel under Admin → Teams, beside the forum settings, rather than an extension of
DiscordBotAdmin. A second platform would replace "Discord" with whatever a capability registry
declares (§8.2); what should change then is what fills the panel, not where an operator goes to find
it. Phase 10 would have built that registry and is cancelled — which changes nothing here, because
the reason this panel is not inside the Discord one is that an operator should not have to know which
platform is configured to find it. It is the one
admin-only corner of a staff-wide router: this is not the §2.9 kind of decision a moderator files
a request for, it is deployment configuration, and it sits with the role that already holds the bot
token.
What the rig proved, and the two defects it found
Real ServUO + real sidecar (protocol 4) + the app with module-uo installed, with a fake standing in for Discord. It proved the default row governing a Team with no row of its own, a per-Team override beating it (including an override that switches the bridge OFF for one Team while the default stays on), the 422 on an unacknowledged forum bridge, the acknowledgement clearing on a repoint, forums switched off silencing the bridge along with the push, and a bot that is down costing the forum reply nothing.
Both defects came out of tests written against the rig's shapes. A re-acknowledgement given for a NEW channel kept the OLD attribution — the column was already 1, so "freshly acknowledged" read false and the row went on naming whoever vetted the previous destination, which is the entire audit value of the column. And the embed description was clamped to Discord's limit before the heading was prepended, producing a description one heading over the limit; discord.js rejects that outright, so an over-long forum post would not have arrived at all rather than arriving truncated.
Not done here. No real Discord guild was involved — channels.fetch and a real channel.send
are the two things this walk could not exercise, the same gap phase 7 recorded for REST.put.
Phase 9 — Discord: voice channels (website) — DONE 2026-08-19
team_integrations, the threshold gate, the shared category, a per-Team role (not overwrite
management with escalation — see §7.3's amendment), the grace-window lifecycle, and the stale-sync
suspension.
Ships: every Team above the operator's size threshold gets a voice channel of its own in Discord, visible and joinable by its members and nobody else.
ONE code repo, not the plan's website + bot. bot is a workspace inside website — the same
correction phases 7 and 8 made. module-uo is untouched and MODULE_API_VERSION does not move.
Org-lead decisions (2026-08-19), all four settled before any code: roles always, no overwrite escalation · the bot creates the parent category and the server stores its id in settings · the threshold counts every active member, not linked ones · "staff" is a list of Discord roles the admin designates, because the concept does not otherwise exist.
Walked on the live rig before the PRs opened, per the order phase 5 set — real MariaDB, the real app, and a fake standing in for Discord that mounts the bot's real internal routes, so everything up to the Discord API call was production code. 47 assertions.
What the walk proved, and the two defects it found
It proved: the preflight refusing an enable three different ways and the panel still rendering with a
broken bot; a category, role and channel created with @everyone denied and the Team role allowed;
the hidden Team and the below-threshold Team getting nothing; the role granted to the two members in
the guild and not to the one who linked Discord without joining it; a drop below the threshold
scheduling a removal with zero Discord calls; a recovery inside the window keeping the same
channel id; an expired window deleting the channel and the role and forgetting the row; a stale
projection suspending the pass in both directions; voice switched off leaving the channels standing;
and an admin removal working anyway, with a 404 for a Team that has none.
- Every query failed on a duplicate result column.
desiredTeamsandholdersWithoutClaimboth selectt.id AS team_id, and the shared column list addedi.team_idbeside it — which themariadbdriver refuses outright ("Error in results, duplicate field nameteam_id"). The pass died at its first query, on the one code path every unit test stubs. It was also the wrong column:desiredTeamsLEFT JOINs, soi.team_idis NULL for exactly the Teams that have no channel yet. - "Sync now" reported "Nothing was done" while it was doing it. Saving the settings with voice on
asks for a pass; an operator pressing Sync now next — the obvious thing — got "a pass is already
running" and a panel saying nothing had happened, while the pass they triggered created their
channels. A pass in flight is now joined and its real outcome returned, as
reconcileNowdoes.
Two things outside this phase that it had to work around
npm run swaggercould not run at all onedge. Phase 8 shipped a regex literal followed directly by.test(in a route validator, which makes swagger-autogen's parser run away and the process die out of memory. Hoisted to a const. Underneath it,teams.router.jssits exactly at that parser's per-file limit: at twentyteamsRouter.*statements it dies and at nineteen it generates, and one more statement of any shape tips it — an unannotated route does, and so does a bareuse. The voice routes are therefore their own router file, mounted fromadmin/index.js.last_success_atis written by MariaDB'sNOW()and compared against JSDate.now(), so an app process and a database in different timezones skew every staleness judgement by the offset — which moves §3's public freshness banner as much as this phase's suspension. Pre-existing and not fixed here; recorded because it is invisible until something depends on it.
Phase 10 — the capability layer (website + docs) — CANCELLED 2026-08-19
Not built, and not scheduled. The org lead cancelled this phase after Phase 9, deferring it until a second integration is actually wanted or it is asked for by name. What follows is what it would have done, kept because the argument for it survives its cancellation.
Refactor Phases 7–9's Discord code behind the declared-capability registry (§8.2) and prove it by rendering the admin UI from the declaration rather than from a hardcoded "Discord" assumption. Last on purpose: extracting a capability surface from one working implementation is honest; designing it before one exists is speculation.
Why cancelling it costs little. The same argument that put it last is the argument for not doing it yet: with exactly one integration built, the refactor would extract a capability surface from a single implementation and have nothing to check the extraction against. §8.2's Matrix column is research, not a second implementation, and a registry whose only consumer is the thing it was extracted from is a layer of indirection that has not yet been paid for. The work is cheaper and better-informed the day a second platform exists, because that platform is what proves which of the five capabilities the seam actually needs.
What it leaves behind, stated so nobody has to re-derive it. Phases 7–9 name Discord directly —
in the bridge config (team_discord_config), the voice provisioner, the slash-command dispatcher and
their admin panels. That is not a defect and no code is placed differently in anticipation of a layer
that may never come. Two decisions were made for this phase, and both stand on their own:
the notification bridge and the voice panel live under Admin → Teams rather than inside the
Discord Bot panel (§7.2, §7.3), because where an operator goes to find them should not depend on which
platform fills them; and core's calls are already phrased as questions about eligibility — these user
ids are eligible for Team 3 — rather than as instructions about overwrites. A second integration
would be a new phase against that surface, not a rescue of this one.
Phase 11 — the integration kit (integration-kit) — the last phase before the cutover
The kit is the instruction book for putting a different game on this platform, written for an
audience outside this org. Teams expands the contract that book teaches against, so the book is the
last thing this bet owes before edge becomes main.
One sentence in it is already wrong. book/02-website-module.md states, of extension slots,
"core declares a slot; a module may only fill one". Phase 3 inverted exactly that: with
declareModuleSlot a MODULE declares a place on its own page and CORE fills it, and by phase 6
module-uo declares three. A new game's module cannot implement Teams at all without the inverted
direction, so this is not a stale detail — it is the shape the reader needs and does not have.
Two genuinely new shapes to teach, and only two:
- The inverted slot (§3.7a) — a module declaring a place for core, why the name is namespaced under the module's own id, and why a module wants separate slots rather than one (it decides where each of core's contributions sits on a page it owns).
registerTeamProvider— the first registration where core calls the module and waits. Every other one is the module claiming a mount or core notifying it. The envelope, the 10-second budget, and the asymmetry that matters: every call fails stale (core keeps what it has) exceptprojectRoster, which fails closed, because for a visibility question "keep what you have" means serving the roster unprojected.
pageUrlTemplate is a footnote beside those — one optional string, and the reader meets it while
reading the provider.
What this phase explicitly does NOT do: enumerate the contract. The kit already teaches only four
members and has never mentioned registerNotificationStreams, registerAnnounceLeg or
registerPostHook, all of which predate Teams. That is the design, not a gap:
MODULE_API.md is normative and the kit teaches one path end to end and links out.
The question this phase answers is "did the teaching path change", and the answer is yes in two
places and no everywhere else.
Amended 2026-08-19, as built. The phase ran before the cutover, as planned, and it found what it was meant to find. Four notes.
The inverted slot did not work for anyone but
module-uo, and the kit is what proved it. Core filled three literaluo.guild.*names, so a second game's module declared its places under its own id and core filled none of them — an empty page, no error, nothing logged, because "a fill for a slot nobody declared is not an error" is exactly the rule that hides an unknown name. Settled by the org lead the same day: core offers a CONTRIBUTION and never names a slot, amended into 1.6.0 in place since it has only ever been onedge(website#160, Module-uo#15, docs#165). The kit could not have taught the shape honestly without this, which is the argument for having written the book before the cutover rather than after it.The template grew a real provider rather than a snippet (org lead, 2026-08-19). It registers
registerTeamProviderover two tables of its own, declares three slots on a clan page, and serves its own/clans— deliberately not/teams, which is core's and which the loader would refuse. The guards that matter are the ones a reader would otherwise omit: an unreachable game refuses rather than reporting no clans, an empty roster is refused unless the game says the clan is empty, and one audience rule serves bothprojectRosterand the module's own page.It was walked on a live rig before the PRs opened — real MariaDB, the real loader, a browser. Core reconciled two Teams out of the provider on the first boot,
/public/teams/<slug>/membersansweredprojected: true, and the clan page rendered core's activity feed and forum in the slots the module declared.module-uo's guild page was walked on the same core and is unchanged. The walk found one defect no test could:PageHeadertakeslead, notsubtitle, and React drops an unknown prop silently — so every page built from the template had rendered its heading with nothing under it since the template was written.Phase 10's cancellation makes this the last phase, and nothing in it changed as a result.
Then the two mechanical lines: ci/core-ref.json's sha moves to the cutover commit and
template/module.json's coreApi becomes ^1.6.0, which puts scripts/checkCoreApi.js back to
green. That check is an equality, and its going red is the mechanism rather than a bug — a
contract bump is meant to turn that repo red until someone has re-read the chapters. Moving the pin is
that person saying they have.
Ordering, stated because it is genuinely awkward. This phase is written before the cutover and can only merge after it. CI clones the pinned sha and checks the template against that core's
MODULE_API_VERSION— and 1.6.0 does not exist onmainuntil the cutover lands, so there is no sha to pin and no core for the template to build against until then. Write the chapters last, open the PR once the cutover merge exists, and put the pin move in it.
Checks that gate it (all dependency-free Node scripts, run from the repo root — which is also how a
reader runs them): checkLinks, checkRenameSites, checkChapterPaths, checkCoreApi --core .core,
plus the template's own npm ci / check:imports / build / check:externals / npm test on both
halves. Build the client before the client tests; two of them read the built chunk.
Cross-cutting, every phase that touches the server
npm run swagger regenerated and committed · npm run routes:manifest -- --check zero-line diff ·
BACKEND_DESIGN.md updated · forward-only idempotent schema fragments · scoped-router isolation ·
npm test green in website/server and website/client. Phase 1 additionally needs the installer
bundle to pair a protocol-4 sidecar with a protocol-4 overlay.