Compare commits
1 Commits
e38792b21b
...
docs/teams
| Author | SHA1 | Date | |
|---|---|---|---|
| 12bd24a973 |
@@ -12,18 +12,11 @@ design comes first, because a finding is only worth anything with the design tha
|
||||
|
||||
Rust was chosen because it is unlike Ultima Online in the ways most likely to break assumptions:
|
||||
it **wipes** every month, a community runs **several servers** rather than one shard, its identity
|
||||
is **Steam**, and its server is **not source you can compile** — ServUO's overlay is C# a shard owner
|
||||
builds into their own server, and a Rust server is a binary nobody outside Facepunch patches. The way
|
||||
in is a **mod** — specifically an **Oxide** plugin, since Oxide is what modded Rust servers run —
|
||||
hooking the game's own events. If the contract survives that, "game-agnostic" means something.
|
||||
is **Steam**, and its server speaks a **protocol nobody has to write** — RCON over WebSocket, built
|
||||
in. If the contract survives that, "game-agnostic" means something.
|
||||
|
||||
> Nothing here re-specifies the contract. [`../website/MODULE_API.md`](../website/MODULE_API.md) is
|
||||
> normative; this document only *uses* it.
|
||||
>
|
||||
> **Revisited 2026-08-19, for Teams** (`MODULE_API_VERSION` 1.6.0, Teams phase 11). A Rust team is a
|
||||
> Team, so the design grew a provider and an inverted slot — the two places the contract changed since
|
||||
> this was written. Everything else stands, including all four findings: the identity gap is still
|
||||
> open and still the one a real second module hits first.
|
||||
|
||||
---
|
||||
|
||||
@@ -97,54 +90,30 @@ module.exports = function register(ctx, api) {
|
||||
api.registerAnnounceLeg({
|
||||
leg: 'rust.ingame',
|
||||
label: 'In-game chat',
|
||||
dispatch: (post) => links.broadcast('chat.say', { text: `[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}` }),
|
||||
dispatch: (post) => rcon.say(`[NEWS] ${post.title} — ${ctx.site.baseUrl}/news/${post.slug}`),
|
||||
classify: (result) => (result.ok ? { outcome: 'done' } : { outcome: 'retry', error: result.error }),
|
||||
})
|
||||
|
||||
// Teams (1.6.0). A Rust "team" is a Team: core owns the tables, the membership
|
||||
// sync, the access rules, the forum and the activity feed; this module owns the
|
||||
// word and the roster behind it.
|
||||
api.registerTeamProvider(teamProvider)
|
||||
|
||||
api.onBoot(async () => { await links.connectAll() })
|
||||
api.onShutdown(async () => { await links.closeAll() })
|
||||
api.onBoot(async () => { await rcon.connectAll() })
|
||||
api.onShutdown(async () => { await rcon.closeAll() })
|
||||
}
|
||||
```
|
||||
|
||||
Everything above is a call the contract already has, used the way module-uo uses it. Three details
|
||||
are worth pointing at:
|
||||
Everything above is a call the contract already has, used the way module-uo uses it. Two details are
|
||||
worth pointing at:
|
||||
|
||||
- **`rust.wipe` and `rust.raid` are namespaced**, with no grandfathering request. Module-uo's seven
|
||||
bare stream ids are allowlisted because they were in `notification_subs` before the rule existed
|
||||
(§6.5); a new module gets the rule, and the rule is exactly right.
|
||||
- **The announce leg goes to in-game chat**, by asking each server's sidecar to send a command down
|
||||
the socket its plugin already holds — a one-shot delivery with retry, so `registerAnnounceLeg` and
|
||||
not `registerPostHook`. Note the plural: there is one sidecar per server, so a news post reaches
|
||||
every configured server and the leg's `classify` answers for the set. The distinction §2.4 draws holds up on a game that has nothing in common with
|
||||
the one it was drawn for. Note the direction: the module never speaks to a game server, and the
|
||||
*sidecar* never dials one either — it answers on a connection the plugin opened.
|
||||
- **The Team provider is the one registration core calls back into**, and Rust makes two of its rules
|
||||
bite harder than UO does. `externalId` must survive a rename, and a Rust team has no name at all —
|
||||
it is a numeric team id in the server's save, which is the right answer and the one a designer is
|
||||
least likely to reach for. And **`complete` is per SERVER, not per community**: a community running
|
||||
six servers has six team spaces, so a provider that can reach five of them must leave `complete`
|
||||
off or core archives every Team on the sixth. Wipes make the same point once a month, on purpose —
|
||||
a wipe empties every team, and `{ ok: true, complete: true, teams: [] }` is then *true* and core
|
||||
archiving all of them is *correct*. Which is exactly why a sidecar with no plugin connected must
|
||||
answer `{ ok: false }` instead: the two states are one API call apart and only the module can tell them
|
||||
apart.
|
||||
- **The announce leg goes to in-game chat over RCON**, which is a one-shot delivery with retry —
|
||||
`registerAnnounceLeg`, not `registerPostHook`. The distinction §2.4 draws holds up on a game that
|
||||
has nothing in common with the one it was drawn for.
|
||||
|
||||
### Tables
|
||||
|
||||
`rust_servers`, `rust_wipes`, `rust_players`, `rust_player_stats`, `rust_teams`, `rust_events`,
|
||||
`rust_bans`, `rust_maps`. All `rust_`-prefixed, all in one idempotent `schema.sql` fragment.
|
||||
|
||||
**`rust_teams` stays this module's table, and core's `teams` stays core's.** They hold the same teams
|
||||
and neither reads the other: the module ingests from the sidecar into `rust_teams`, core reconciles by
|
||||
*asking* the provider, and §2.6's prefix rule forbids the module touching core's table even though
|
||||
the module is what populates it. A module that wrote `team_members` directly would be racing core's
|
||||
reconciler for rows it does not own.
|
||||
|
||||
**Every table that holds gameplay data carries a `wipe_id`.** That is the whole shape of the game in
|
||||
one column: a leaderboard means "since the last wipe", a base means "on this map", and a player's
|
||||
stats are per-wipe with an all-time rollup kept separately. It has no bearing on the contract —
|
||||
@@ -153,72 +122,22 @@ model gets wrong, and it is worth writing down for whoever builds this.
|
||||
|
||||
### Talking to the game
|
||||
|
||||
**An Oxide plugin, a sidecar, and the same three-part shape UO has.** Rust's server is a binary, so
|
||||
there is no overlay to compile into it and no source to patch — but a modded server runs **Oxide**,
|
||||
and an Oxide plugin is C# with a hook for everything this design needs. So `rust-link` is a real
|
||||
sidecar rather than a wrapper around an admin channel, and it is fed the way `uo-link` is fed:
|
||||
**A thin sidecar.** Rust ships RCON over WebSocket, so `rust-link` is small: it holds the RCON
|
||||
connection to each server with the token an admin saved, and presents the website the same shape
|
||||
`uo-link` does — a bearer-authed HTTP + WebSocket API in front of a SQLite store. The module talks
|
||||
only to it, never to a game server.
|
||||
|
||||
```
|
||||
ONE Rust server + the rust-bridge Oxide plugin (C#, hooks)
|
||||
│ loopback TCP, newline-delimited JSON, bidirectional
|
||||
│ the PLUGIN dials out to the sidecar — the game opens no listening port
|
||||
▼
|
||||
ONE rust-link sidecar, on that same host
|
||||
│ bearer-authed HTTP + WebSocket, versioned
|
||||
▼
|
||||
module-rust, inside the website — one client per server it is configured with
|
||||
```
|
||||
|
||||
**One server, one sidecar** (org lead, 2026-08-19). Not one sidecar fronting a community's several
|
||||
servers, which is the arrangement a UO-shaped reading reaches for. Rust servers in practice sit on
|
||||
**separate VMs**, so a shared sidecar would have to be reached across a network by plugins that are
|
||||
supposed to talk to it over loopback — trading the invariant that makes this design safe for a saving
|
||||
in process count. The pairing stays local: a server, its plugin, and its own sidecar on the same host.
|
||||
|
||||
The cost lands on the module, and it is the right place for it: `module-rust` holds one client per
|
||||
configured server rather than one client to one aggregator, and every board it reads is that server's.
|
||||
A community with six servers runs six pairs and the module knows about six endpoints. **That is a
|
||||
reevaluable assumption rather than a principle** — if a deployment ever wants one sidecar for several
|
||||
servers, nothing in the contract objects, because core is not in this conversation at all.
|
||||
|
||||
**The plugin is the interesting half, and it is where a UO-shaped model has the least to unlearn.** The
|
||||
threading contract transfers whole — emit enqueues onto a bounded drop-oldest queue and returns, one
|
||||
writer thread owns the socket, the world is read only on the game's own thread — because it is a
|
||||
property of *game servers* and not of ServUO. What changes is that the hooks are handed to you rather
|
||||
than found: Oxide publishes them, so the plugin is small and the guesswork is in deciding what to
|
||||
emit rather than in finding somewhere to hang it.
|
||||
|
||||
Two of them answer questions UO had to work for:
|
||||
|
||||
- **The wipe arrives as an event.** The plugin is told a new save has begun; nothing has to detect a
|
||||
wipe by noticing the world looks different.
|
||||
- **Membership is real-time** — created, joined, left, disbanded, leader changed. UO has no
|
||||
`guild.leave` at all and needed a 60-second sweep plus a set diff to synthesise one (protocol 4,
|
||||
[`../link/v4.md`](../link/v4.md)); here every transition is delivered as it happens. So this
|
||||
module's Team provider is **event-driven with a baseline on connect** rather than sweep-driven —
|
||||
and the provider contract does not change by one line, because core asks the same three questions
|
||||
and gets the same envelope. That is the result worth keeping: the contract never needed to know how
|
||||
the data arrives.
|
||||
|
||||
**The sidecar still earns its place, and the store is why.** A hook fires once, and what it says while
|
||||
nobody is listening is gone. So the sidecar appends every kill, wipe and chat line, keeps the latest
|
||||
snapshot of its server's state, and answers the website's reads from disk — a website that is down,
|
||||
restarting or mid-deploy loses nothing, and a leaderboard renders the last thing the server said
|
||||
rather than an error. It also keeps the auth token and the reconnect loop out of an Express process,
|
||||
where a stalled socket is a stalled request handler.
|
||||
|
||||
**A sidecar answers for exactly one server**, which is what makes the Team provider's `complete`
|
||||
(§2) answerable at all: "every team there is" means every team on *this* server, and the module can
|
||||
only claim it for the servers whose sidecar answered. Five of six reachable is `complete` left off,
|
||||
and core then adds and updates without archiving anything.
|
||||
The store is the reason it exists even though the game is already remote-controllable. RCON is a
|
||||
live channel with no memory: what it tells you while nobody is listening is gone. So the sidecar
|
||||
appends every kill, wipe and chat line, keeps the latest snapshot of each server's state, and
|
||||
answers the website's reads from disk — a website that is down, restarting or mid-deploy loses
|
||||
nothing, and a leaderboard renders the last thing the server said rather than an error. It also
|
||||
keeps the RCON token, the reconnect loop and the per-server fan-out out of an Express process, where
|
||||
a stalled socket is a stalled request handler.
|
||||
|
||||
**`wipe_id` makes the durable copy load-bearing rather than a nicety.** A wipe is the moment the
|
||||
game forgets; the sidecar is the only thing that remembers the shape of the map that just ended.
|
||||
|
||||
**Commands go back down the same socket.** The announce leg's in-game chat line is a command the
|
||||
sidecar sends to its plugin — the same direction UO's bridge already carries. The connection belongs
|
||||
to the plugin, and nothing outside the game ever dials into it.
|
||||
|
||||
> **Correction, 2026-08-12.** This section originally concluded **"No sidecar"** — the module dialling
|
||||
> RCON directly — and offered it as evidence that core has no opinion about how a module reaches its
|
||||
> game. That was overruled by the org lead when Phase 5 (§2.11.1 d3/d4) settled the kit's stance, and
|
||||
@@ -231,21 +150,6 @@ to the plugin, and nothing outside the game ever dials into it.
|
||||
> to be restarted and the one facing the internet. The finding is left in view rather than edited out;
|
||||
> what a dry run concluded is worth more than a tidy document.
|
||||
|
||||
> **Correction, 2026-08-19 (org lead).** This document originally reached the game over **RCON**, and
|
||||
> this section concluded "a thin sidecar" on that basis — no wire protocol to invent and no game-side
|
||||
> plugin to write. Overruled: **the transport is an Oxide plugin, exactly as UO's is a ServUO
|
||||
> overlay, and RCON is not used.** Hooks inside the plugin expose the data and the plugin dials the
|
||||
> sidecar. **One server, one sidecar**, since Rust servers usually sit on separate VMs — a reevaluable
|
||||
> assumption, recorded as one.
|
||||
>
|
||||
> Two things that costs the document, worth stating because both were used as evidence elsewhere.
|
||||
> Rust is no longer an example of *"a game that already speaks a remote-control protocol, so its
|
||||
> sidecar is thin"* — that example now has none in this project. And the reason Rust is a good second
|
||||
> game is no longer that its protocol comes free. It is that its server is a **binary**, the opposite
|
||||
> of ServUO: the way in is a published mod API rather than source you compile, and the
|
||||
> shard-dials-out invariant has to survive that change of footing. It does, unchanged, which is a
|
||||
> stronger result than the one this document originally claimed.
|
||||
|
||||
## 3. The client half
|
||||
|
||||
```js
|
||||
@@ -279,13 +183,6 @@ registry.registerNav('rust', {
|
||||
|
||||
registry.registerFeatureProvider('rust', 'rust', useRustFeatures)
|
||||
registry.registerExtension('rust', 'admin.users.detail', LinkedSteamAccounts)
|
||||
|
||||
// The INVERTED direction (1.6.0): this module declares places on its OWN team
|
||||
// page and core fills them. Core publishes no team page — it does not own the
|
||||
// word — so `/rust/servers/:id/teams/:teamId` is this module's, and core's feed
|
||||
// and forum are contributed into it.
|
||||
registry.declareModuleSlot('rust', 'rust.team.detail', { core: 'team.activity' })
|
||||
registry.declareModuleSlot('rust', 'rust.team.forum', { core: 'team.forum' })
|
||||
```
|
||||
|
||||
`Play` is a group core does not have; §3.3 appends an unknown group rather than dropping the items,
|
||||
@@ -293,15 +190,9 @@ so this works and lands at the end of the nav — where an operator can move it,
|
||||
is an ordinary row once it is interleaved.
|
||||
|
||||
The pages need `PublicLayout`, `PageHeader`, the three `PageState` components, `useAsync` and
|
||||
`useAuth`: **six of the kit's nine members**, plus `useSite` on the wipe-schedule page for the site's
|
||||
timezone and `Slot` on the team page. A second game, unrelated to the first, wanting exactly what the
|
||||
kit contains is the strongest evidence available that §3.4 was curated at the right altitude.
|
||||
|
||||
**The team route carries a server id as well as a team id**, which is the Rust-shaped consequence of
|
||||
the finding two sections down: team `4` on one server and team `4` on another are different teams,
|
||||
so the module's `externalId` has to be `<serverId>:<teamId>` and its page needs both. Core stores
|
||||
that string and never parses it — an external id is opaque to core by design, and this is the case
|
||||
that shows why.
|
||||
`useAuth`: **six of the kit's seven members**, and the seventh (`useSite`) on the wipe-schedule page
|
||||
for the site's timezone. A second game, unrelated to the first, wanting exactly what the kit
|
||||
contains is the strongest evidence available that §3.4 was curated at the right altitude.
|
||||
|
||||
The map view is the one page that wants something the kit does not have — a pan/zoom canvas. It
|
||||
bundles one, which is the answer §3.4 already gives ("everything else a module bundles itself"), and
|
||||
@@ -346,10 +237,6 @@ The contract is silent on this, and silence turns out to be right: multiplicity
|
||||
module's own tables and route parameters (`/rust/servers/:id`). Core's mount prefixes, capabilities
|
||||
and state machine are per-**module**, and none of them wanted to be per-server.
|
||||
|
||||
The one-server-one-sidecar decision above pushes that further and it still holds: the module holds
|
||||
*several* sidecar clients, and core never learns there is more than one. What core has an opinion
|
||||
about is the module; how many things the module talks to is the module's business.
|
||||
|
||||
Worth recording only because it looks like a problem until you try it — and because it is the shape
|
||||
that would have broken a contract designed around "the shard" as a singular noun. The phase-2
|
||||
inversions that removed core's opinions about game content (the push catalog, the announce legs,
|
||||
@@ -389,16 +276,11 @@ load-bearing with two.
|
||||
## Verdict
|
||||
|
||||
**The contract generalises.** A second game, chosen for how little it shares with the first, is
|
||||
served by the same `module.json`, the same registration calls, the same schema-fragment rules,
|
||||
served by the same `module.json`, the same seven registration calls, the same schema-fragment rules,
|
||||
the same client registry and the same UI kit — with one genuine gap (identity providers), one
|
||||
non-issue that looks like a gap (multiple servers), and two places where a rule written for one
|
||||
reason turns out to cover another.
|
||||
|
||||
**And the three-part shape generalises with it**, which the 2026-08-19 correction is what actually
|
||||
established: a game whose server is a binary, reached through a published mod API, still ends up with
|
||||
a plugin that dials out, a sidecar that persists before it forwards, and a module that talks only to
|
||||
the sidecar. Nothing about that arrangement was a property of ServUO being source you can compile.
|
||||
|
||||
The gap is worth having found before something was built on top of it, which is what a dry run is
|
||||
for. What it does **not** establish is that someone outside this org could build this module from the
|
||||
documentation alone — that is the Integration Kit's acceptance test (§2.11), and it stays untested
|
||||
|
||||
@@ -553,8 +553,6 @@ core's.
|
||||
| `team_forum_moderation` | append-only, per Team, recording `actor_role` — WHICH authority was exercised. Deliberately not merged with `mod_actions`/`appeals`, which is Discord-sanction-shaped |
|
||||
| `team_forum_uploads` | attribution for `uploads` mode: who uploaded what, when, how big, and to which post. Also the sweep's worklist |
|
||||
| `team_notification_prefs` | per-Team notification preference (phase 6). **Opt-out for push, opt-IN for email** — `muted` defaults 0 and `email_mode` defaults `'off'`, so the two sinks default opposite ways and the asymmetry lives here rather than in a condition anyone has to remember. Team scoping lives in this table and in the recipient computation, never in a stream id. `last_digest_at` is the digest's only state and the worker is its only writer |
|
||||
| `team_integration_config` | where a Team's notifications go on another platform (phase 8). One row per (platform, Team) plus a **deployment-wide default** whose `team_id` is NULL — expressed with a generated `team_key AS IFNULL(team_id, 0)` in the unique key, because a NULL cannot live in a primary key and the default row is the base case of the whole override mechanism. `members_ack` is a **precondition, not a preference**: forum posts and announcements are members-only always, core cannot see a channel's permissions, so enabling one requires an attributed operator acknowledgement that the destination is restricted — and changing the channel clears it |
|
||||
| `team_integrations` | a Team's provisioned resource on another platform — today its Discord **voice channel and the role that opens it** (§7.3, phase 9). Both refs on one row because they are one lifecycle: a role for a channel that no longer exists is a badge for nowhere. `state` is core's BELIEF about the platform, never the platform's answer — the reconciler writes what it just did and the next pass re-derives the truth. A Team that stops qualifying goes to `pending_removal` with `remove_after` rather than being deleted at once, so a Team hovering around the size threshold does not delete-and-recreate its channel and change its id. `synced_at` is separate from `updated_at`, which moves whenever core writes a belief including an error |
|
||||
| `content_reports` | member-raised abuse reports (phase 5). **Not a `team_*` table and not named for the forum** — `target_type` is a plain VARCHAR so a wiki page or a news comment becomes a value rather than a table. Team forum content is only the first consumer |
|
||||
|
||||
**Core had no user-facing report flow of any kind before `content_reports`.** `moderation`,
|
||||
@@ -676,7 +674,7 @@ are authoritative, and they answer different questions:
|
||||
|
||||
| Artifact | Source of truth for | Generated by |
|
||||
|---|---|---|
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** Every core URL — the public app plus the internal listener — sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/routes.manifest.json` — mirrored as [api-route-inventory.json](./api-route-inventory.json) | **What URLs CORE serves.** 166 public routes + 2 on the internal listener, sorted, method + path only. | `npm run routes:manifest`, by walking the live Express stack |
|
||||
| `server/swagger/swagger-output.json` — merged into `/api/docs` | **What each core route means.** Parameters, bodies, response codes, security. | `npm run swagger`, from `#swagger.*` annotations |
|
||||
|
||||
Both are **core's**. An installed module's routes are in neither: they are in that module's own
|
||||
|
||||
@@ -37,16 +37,8 @@ module chunk evaluates, which is earlier than any network round trip could answe
|
||||
`module-uo`'s `coreApi: "^1.3.0"` still resolves. `api.registerTeamProvider(...)` and
|
||||
`ctx.teams.publish` / `ctx.teams.reconcile` (§2.3, §2.4a) · `ctx.teams.activity.push` ·
|
||||
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
|
||||
`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
|
||||
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
|
||||
|
||||
> **Amended 2026-08-19 (phase 11), on the org lead's decision.** `declareModuleSlot` takes an
|
||||
> optional `{ core }` naming which of core's contributions belongs in the declared place, and core
|
||||
> offers contributions instead of naming slots (`CORE_CONTRIBUTIONS`, §3.7a). In 1.6.0 in place, by
|
||||
> the same rule as the two amendments below: 1.6.0 has only ever been on `edge`. It is a **correction
|
||||
> and not an addition** — as first written, core filled three literal `uo.guild.*` names, so the
|
||||
> inverted direction worked for exactly one module and silently did nothing for any other, which the
|
||||
> integration kit found while trying to teach it to an audience outside this org.
|
||||
>
|
||||
> **Amended 2026-08-17 (phase 3), on the org lead's decision.** Two changes.
|
||||
>
|
||||
> **Amended again 2026-08-18 (phase 6), on the org lead's decision.** A **ninth** member,
|
||||
@@ -64,12 +56,14 @@ the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSl
|
||||
> Both assumed core rendered a Team page. It does not: **Teams is a contract primitive, not a
|
||||
> surface** — core owns the tables, the sync, the access rules and the activity feed, and does not own
|
||||
> the word for one, so the module that owns the vocabulary owns the page. In their place,
|
||||
> `registry.declareModuleSlot(id, name, { core })` lets a MODULE declare a place on its own page for
|
||||
> CORE to fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
|
||||
> `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on its own page for CORE to
|
||||
> fill, and `Slot` joins the UI kit so the module can render it. See §3.7a.
|
||||
|
||||
**Every member of 1.6.0 is live as of phase 7.** `api.registerSlashCommands` was the last one still
|
||||
throwing, and it now registers — the staged rollout the paragraphs above describe is finished. A
|
||||
module may call any member of this version and get the behaviour documented below.
|
||||
**The number covers the whole surface; the members arrive by phase, and each is marked below.** Seven
|
||||
are live now. `api.registerSlashCommands` is **present and throws**, with an error naming the phase
|
||||
that will implement it — chosen over leaving it absent so that a module written against the published
|
||||
version fails at registration with a sentence explaining itself, rather than at whatever moment
|
||||
someone first exercises the feature. Do not call it yet; do not treat its throw as a bug.
|
||||
|
||||
**`registerTeamProvider` is the first registration where core calls the MODULE and waits.** Every
|
||||
existing one is either the module claiming a mount or core notifying it; the closest precedent is
|
||||
@@ -307,7 +301,7 @@ api.registerNotificationStreams(streams)
|
||||
api.registerAnnounceLeg({ leg, label, dispatch, classify })
|
||||
api.registerPostHook({ onSaved, onDeleted })
|
||||
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders }) // 1.6.0
|
||||
api.registerSlashCommands([{ name, description, options, access, handler }]) // 1.6.0
|
||||
api.registerSlashCommands([...]) // 1.6.0, throws until phase 7
|
||||
api.onBoot(async (ctx) => {})
|
||||
api.onShutdown(async () => {})
|
||||
```
|
||||
@@ -498,74 +492,10 @@ removals. It defaults to `true` when omitted, so the ordinary authoritative case
|
||||
omitted from a roster is indistinguishable, downstream, from that member having left — core would
|
||||
mark them departed on the strength of a broken payload. Refusing costs one interval of staleness.
|
||||
|
||||
**`registerSlashCommands(commands)`** — chat-platform commands whose definition AND handler both
|
||||
belong to the module, live since phase 7 (TEAMS.md §7.1).
|
||||
|
||||
```js
|
||||
api.registerSlashCommands([{
|
||||
name: 'guild', // lowercase, 1-32, no dots
|
||||
description: 'Show a guild on this shard', // 1-100 characters
|
||||
options: [ // the restricted schema, below
|
||||
{ name: 'name', type: 'string', description: 'Guild name or abbreviation', required: false },
|
||||
],
|
||||
access: 'everyone', // 'everyone' | 'linked' | 'staff'
|
||||
async handler({ command, options, actor }) {
|
||||
return { title, text, fields, url, ephemeral, notice } // every field optional
|
||||
},
|
||||
}])
|
||||
```
|
||||
|
||||
**The handler runs in the WEBSITE process, never in the bot.** The bot container has no `modules`
|
||||
volume and cannot load a line of module code, so it pulls the definitions over an internal API and
|
||||
owns every platform-specific concern — deferral, the acknowledgement deadline, ephemerality,
|
||||
follow-ups, embeds. A module that wanted to call `interaction.deferReply()` would be a module holding
|
||||
a Discord handle, and this split is the reason a second platform could implement the same contract.
|
||||
|
||||
**`actor` is resolved by core before the handler is entered**, and is the whole of what a handler
|
||||
learns about the caller:
|
||||
|
||||
| field | |
|
||||
| --- | --- |
|
||||
| `platform` | `'discord'` today; the only platform-shaped thing a handler ever sees |
|
||||
| `platformUserId` | the caller's id on that platform |
|
||||
| `guildId` | the platform community the command was run in, or `null` |
|
||||
| `userId` | the site account, or `null` when the platform identity is not linked |
|
||||
| `role` | that account's role — a module with audience rungs needs more than a boolean |
|
||||
| `isLinked` | whether `userId` resolved |
|
||||
| `isStaff` | `admin` or `moderator`, the same two roles every other Team surface means |
|
||||
|
||||
A **banned or disabled** account resolves as unlinked, so a chat surface is never the one place a ban
|
||||
does not reach. The Discord provider is found by `auth_providers.kind`, not by its id — the id is an
|
||||
operator-chosen slug.
|
||||
|
||||
**`access` is enforced twice, and only the server half is the gate.** The bot sets a platform-side
|
||||
permission default from it where the platform can express one; core re-checks it in the dispatcher on
|
||||
every call. `'linked'` has no Discord equivalent at all — there is no "has a website account"
|
||||
predicate — so it is simply not advertised, which is exactly why the client half cannot be the
|
||||
boundary.
|
||||
|
||||
**The option schema is deliberately small: `string | integer | boolean | user`,** each with
|
||||
`required` and optional `choices` (`string` and `integer` only). No subcommand groups, autocomplete,
|
||||
attachments, modals or component interactions — those are the features whose semantics do not survive
|
||||
a second platform. A command needing them is a bot-side command, written in the bot.
|
||||
|
||||
**A definition the platform would reject fails at `register()`**, not at the next connection: the bot
|
||||
registers the whole set in one call, so one bad option type would cost every command, the bot's own
|
||||
included. Names are validated (lowercase, 1-32, no dots), as are description lengths, the option
|
||||
types, and the ordering rule that a required option may not follow an optional one.
|
||||
|
||||
**Commands are NOT namespaced under the module id**, unlike stream ids and announce legs — Discord's
|
||||
name grammar has no `.` in it. Collisions are first-come with the holder named, and a name that
|
||||
collides with one of the bot's own built-ins is dropped by the bot, which is the one collision core
|
||||
cannot see.
|
||||
|
||||
**A handler's failure is its own.** A throw, or a handler still running after core's timeout, becomes
|
||||
a refusal the platform renders; the handler never runs in the bot process, so it cannot cost anything
|
||||
but its own reply. `ok` is core's verdict and sits outside the envelope, so a handler cannot forge it.
|
||||
|
||||
**A disabled module's commands stop answering immediately.** Registration has no removal path — a
|
||||
claim is made once, at load — so liveness is asked at both the pull and the dispatch: an operator who
|
||||
switches a module off does not leave a live handler behind it.
|
||||
**`registerSlashCommands(commands)`** — declared in API 1.6.0 and **not yet implemented**: calling it
|
||||
throws with an error naming the phase that will. Present rather than absent so a module written
|
||||
against the published version fails at registration with an explanation, instead of at the moment
|
||||
someone first types the command.
|
||||
|
||||
**`onBoot(fn)` / `onShutdown(fn)`** — §2.5.
|
||||
|
||||
@@ -1058,7 +988,7 @@ props is a **major** one, because that breaks a call already written. Adding an
|
||||
minor (§1.1). That is a real constraint on core and it is the price of the boundary being worth
|
||||
anything.
|
||||
|
||||
The kit is those **nine exports** — six rows, because `PageState` contributes three. An earlier
|
||||
The kit is those **eight exports** — five rows, because `PageState` contributes three. An earlier
|
||||
draft of this table listed a ninth, `AdminPage`, and core has no such component — admin views are
|
||||
plain markup inside `AdminLayout`. It was struck in Phase 2 PR 7 rather than satisfied by inventing a
|
||||
core component with no consumer until Phase 3; adding it later costs a minor bump, which is the case
|
||||
@@ -1301,11 +1231,10 @@ forced it is worth stating because it will recur:
|
||||
> only core can resolve — are contributed to it.
|
||||
|
||||
```js
|
||||
// In the module's entry chunk, at registration time. The second argument names
|
||||
// which of CORE's contributions belongs in that place:
|
||||
registry.declareModuleSlot(ID, 'uo.guild.header', { core: 'team.notify' })
|
||||
registry.declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
|
||||
registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
|
||||
// In the module's entry chunk, at registration time:
|
||||
registry.declareModuleSlot(ID, 'uo.guild.header')
|
||||
registry.declareModuleSlot(ID, 'uo.guild.detail')
|
||||
registry.declareModuleSlot(ID, 'uo.guild.forum')
|
||||
|
||||
// In the module's page, from the UI kit:
|
||||
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
|
||||
@@ -1313,33 +1242,6 @@ registry.declareModuleSlot(ID, 'uo.guild.forum', { core: 'team.forum' })
|
||||
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
|
||||
```
|
||||
|
||||
**Core offers a CONTRIBUTION; it never names a slot.** This is the part a second game depends on, and
|
||||
the first cut of 1.6.0 had it the other way round — core filled the three literal names above, which
|
||||
worked for `module-uo` and silently did nothing for anybody else: a module declaring `clan.detail`
|
||||
under its own id got an empty page and no error, because "a fill for a slot nobody declared is not an
|
||||
error" is exactly the rule that makes an unknown name invisible. It also put a module identifier
|
||||
inside core, in three string literals `scripts/checkModuleIdentifiers.js` masks by construction and
|
||||
could never have caught (§5.2). Corrected inside 1.6.0, before it reached `main`.
|
||||
|
||||
So the module says WHERE, in its own vocabulary, and WHICH of core's contributions goes there:
|
||||
|
||||
| Contribution *(1.6.0)* | What core puts in the slot | Why it is core's |
|
||||
| --- | --- | --- |
|
||||
| `team.activity` | the Team activity feed | only core can resolve the public/members split on it |
|
||||
| `team.forum` | the Team forum panel | membership and manual grants are core's rules |
|
||||
| `team.notify` | the per-Team notification control | core resolves whether the viewer is in the Team |
|
||||
|
||||
`options.core` is **optional** — a module may declare a place it fills itself, or one it is keeping
|
||||
empty for now. Asking for a contribution core does not offer **throws at the declaration**, and that
|
||||
asymmetry with an unfilled slot is deliberate: core's catalogue is fixed at build time and the
|
||||
module's `coreApi` range has already been checked, so an unknown contribution is always a typo or a
|
||||
version skew, and the alternative failure is a page that renders empty forever with nothing logged.
|
||||
**Adding a contribution is a minor bump**; removing one is major.
|
||||
|
||||
More than one slot may ask for the same contribution and each gets it. Core has no reason to care how
|
||||
many places a module wants its feed in, and refusing the second would be core making a layout decision
|
||||
on a page it does not own.
|
||||
|
||||
**A module declares one slot per PLACE, not one per page.** `module-uo` declares **three** on the same
|
||||
guild page — core fills them with the Team notification control, the activity feed and the Team forum
|
||||
— because a slot holds one component and the first fill wins. Collapsing them would hand core the
|
||||
@@ -1354,25 +1256,25 @@ readable at the fill site.
|
||||
|
||||
**Core fills these at MOUNT, not eagerly, and the ordering is why the call exists at all.** Core's
|
||||
bundle evaluates before every module chunk (§3.1), so at the moment core would like to fill one of
|
||||
these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
|
||||
these the slot does not exist. Core registers its intent (`fillModuleSlot`, core-only) and
|
||||
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
|
||||
render.
|
||||
|
||||
**A contribution nothing asks for is a no-op, never an error.** No game module is installed, which is
|
||||
the ordinary case on any deployment — the exact mirror of an unfilled slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
|
||||
**A fill for a slot no installed module declares is a no-op, never an error.** The declaring module is
|
||||
simply not installed, which is the ordinary case on any deployment — the exact mirror of an unfilled
|
||||
slot rendering nothing. Note the asymmetry with §3.7, where an unknown slot throws: there, an unknown
|
||||
name is always a typo or a version skew, because core declares before any module can name one.
|
||||
|
||||
**First fill still wins**, so a module that fills its own declared slot keeps it and core's fill is
|
||||
skipped. That is deliberate: the module owns the page.
|
||||
|
||||
**`Slot` is the ninth member of the UI kit** (§3.4) for this. A module could not render one of these
|
||||
**`Slot` is the eighth member of the UI kit** (§3.4) for this. A module could not render one of these
|
||||
otherwise, and reimplementing it would mean a second error boundary with different behaviour — which
|
||||
matters more here than anywhere else in the kit, because the thing being contained is *core's* content
|
||||
failing inside the *module's* page.
|
||||
|
||||
`declareModuleSlot` is on the `registry` object handed to modules. `offerCoreFill`, `applyCoreFills`
|
||||
and `CORE_CONTRIBUTIONS` are not: offering into one of these is core's, exactly as declaring a §3.7
|
||||
slot is.
|
||||
`declareModuleSlot` is on the `registry` object handed to modules. `fillModuleSlot` and
|
||||
`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is.
|
||||
|
||||
---
|
||||
|
||||
|
||||
521
website/TEAMS.md
521
website/TEAMS.md
@@ -100,14 +100,11 @@ module fills with anything live. Core does not grow an SSE stack for this.
|
||||
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 }`.
|
||||
(`/internal/config`, `/internal/status`, `/internal/announce`, `/internal/mod-reverse`), 4s timeout,
|
||||
never throws, always returns `{ ok, status, data, error }`.
|
||||
- **bot → app**, `SITE_INTERNAL_URL=http://app:3001/internal/bot-config` on the app's *unpublished*
|
||||
internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
|
||||
restart self-heals. Phase 7 added `bot/src/site/appInternalClient.js` for `/internal/commands` and
|
||||
`/internal/commands/dispatch` on that same listener — it derives the base from `SITE_INTERNAL_URL`'s
|
||||
origin rather than taking a second variable naming the same host.
|
||||
restart self-heals.
|
||||
|
||||
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
|
||||
@@ -830,20 +827,18 @@ named for a *place* and never for a meaning):
|
||||
|
||||
> **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:
|
||||
> `registry.declareModuleSlot(id, name)` — a **module** declares a place on its own page, namespaced
|
||||
> under its own id, and **core** fills it:
|
||||
>
|
||||
> | Slot | Declared by | Rendered in | Filled by core with | Props |
|
||||
> | --- | --- | --- | --- | --- |
|
||||
> | `uo.guild.detail` | `module-uo` | its guild detail page | `team.activity` — the Team activity feed (§4.3) | `{ externalId, moduleId }` |
|
||||
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | `team.forum` — the Team forum (Part 5), added in phase 4 | `{ externalId, moduleId }` |
|
||||
> | `uo.guild.detail` | `module-uo` | its guild detail page | the Team activity feed (§4.3) | `{ externalId, moduleId }` |
|
||||
> | `uo.guild.forum` | `module-uo` | the same page, below the feed | 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-uo` and no other game). `Slot` becomes
|
||||
> the ninth member of the shared UI kit so a module renders the place with core's own error boundary,
|
||||
> Core's fills are applied at MOUNT, not eagerly: core's bundle evaluates before every module chunk,
|
||||
> so when core registers a fill the slot does not exist yet. A fill for a slot no installed module
|
||||
> declares is a no-op, not an error — the mirror of an unfilled slot rendering nothing. `Slot` becomes
|
||||
> the eighth 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.
|
||||
>
|
||||
@@ -1674,42 +1669,6 @@ is already being built there.
|
||||
|
||||
### 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
|
||||
> `/team` as 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 `/team` publishes 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/:slug` does 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-commands` on 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.
|
||||
>
|
||||
> **`actor` carries `role` as well as `isStaff`.** The two answer different questions and a boolean
|
||||
> loses one — `isStaff` is core's gate for `access: 'staff'`, `role` is what a module with its own
|
||||
> audience rungs needs to place the caller on them. It is the pair `projectRoster`'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
|
||||
@@ -1778,20 +1737,10 @@ 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.js` already 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.js` is the sink; the file that
|
||||
> calls it is unchanged in structure.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS team_integration_config (
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'
|
||||
@@ -1804,34 +1753,10 @@ CREATE TABLE IF NOT EXISTS team_integration_config (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
> **This DDL cannot hold its own default row.** MariaDB coerces every `PRIMARY KEY` column to
|
||||
> `NOT NULL`, so `team_id NULL` — the deployment-wide default that every override overrides — is
|
||||
> unrepresentable, and the whole mechanism has no base case. **As built:** a surrogate `id` primary
|
||||
> key, a generated `team_key INT AS (IFNULL(team_id, 0)) STORED` carrying
|
||||
> `UNIQUE KEY (platform, team_key)`, and a real `FOREIGN KEY (team_id) … ON DELETE CASCADE` that 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 one `teams.active_key` and
|
||||
> `content_reports.open_marker` already use. Three further columns carry the gate: `members_ack`,
|
||||
> `members_ack_by` and `members_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`.
|
||||
|
||||
> **`announce` and `mod-reverse` are not the same thing.** `announce` rides `announce_jobs` with
|
||||
> backoff, retries and a per-leg retry button in the admin panel; `mod-reverse` is 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
|
||||
@@ -1839,76 +1764,11 @@ where an empty "something happened, go look" message would be useless. What *is*
|
||||
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 **no `visibility`**. Only `team_activity` rows 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:
|
||||
|
||||
1. **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.
|
||||
2. **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.
|
||||
3. **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.
|
||||
4. **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_max` and the `mode`
|
||||
> column. 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
|
||||
@@ -1918,58 +1778,10 @@ members — because roles are the scarcer guild-wide resource (250 cap) and shou
|
||||
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: `@everyone` denied, the Team's
|
||||
> own role allowed, and one allow per operator-designated staff role. Membership is the role's member
|
||||
> list. There is no `mode`, no `voice_overwrite_max` and no escalation.
|
||||
>
|
||||
> **"the staff role" does not exist in this codebase.** `guild_config` knows 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_identities` row 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 *not*
|
||||
> `teams.linked_count` either — 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.js` already 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 one `hidden = 0` in one query rather than a second policy
|
||||
> that could drift from the first. The name published is `display_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/voice` with `enabled: true` is
|
||||
> 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
|
||||
@@ -1985,83 +1797,34 @@ So: drop below threshold → `state='pending_removal'`, `remove_after` = now + `
|
||||
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()` reports `stale: false` when **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.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS team_integrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
team_id INT NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL, -- 'discord'
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
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
|
||||
mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites',
|
||||
role_ref VARCHAR(64) NULL,
|
||||
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** — `mode` and `role_ref`-as-escalation are gone; `role_ref` is now the grant itself, so a
|
||||
> row with a channel and no role is a broken row. `synced_at` is added: `updated_at` moves 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
|
||||
|
||||
@@ -2116,14 +1879,6 @@ 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: `discord` is 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
|
||||
@@ -2410,9 +2165,6 @@ makes §0.1's roster possible:
|
||||
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
|
||||
@@ -2549,16 +2301,13 @@ guild called "Admin" cannot put an official-looking page on the site.
|
||||
>
|
||||
> **So the extension slots invert, and that is a new `MODULE_API` §3.7 direction.** `team.overview`
|
||||
> and `team.member.row` assumed core rendered the page. They are replaced by
|
||||
> `registry.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-uo`
|
||||
> declares `uo.guild.detail` and asks for `team.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. `Slot` joins the shared UI kit as its ninth member so the module renders the place with core's
|
||||
> own error boundary.
|
||||
> `registry.declareModuleSlot(id, name)`: a **module** declares a place on its own page, namespaced
|
||||
> under its own id, and **core** fills it. `module-uo` declares `uo.guild.detail`; core fills it with
|
||||
> 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's fills are applied at mount rather than eagerly —
|
||||
> core's bundle evaluates before every module chunk, so at the moment core registers a fill the slot
|
||||
> does not exist yet. `Slot` joins the shared UI kit as its eighth 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/:externalId`
|
||||
> is added: core's row id and slug are core-internal and handing them to a module is how a module ends
|
||||
@@ -2736,206 +2485,31 @@ them as toggles automatically, but nothing here builds a Team screen or a deep-l
|
||||
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**
|
||||
### Phase 7 — Discord: slash commands (`website` + `bot` + `docs`)
|
||||
|
||||
`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.
|
||||
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and `/team` as the first
|
||||
command through it.
|
||||
|
||||
**Ships:** a working `/guild`, and the seam a module needs for its own commands.
|
||||
**Ships:** a working `/team`, 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](#71-slash-command-registration)).
|
||||
### Phase 8 — Discord: notifications bridge (`website` + `bot`)
|
||||
|
||||
**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.
|
||||
`team_integration_config`, the internal fan-out with push and bridge as two consumers,
|
||||
`POST /internal/team-notify`, the admin per-event configuration.
|
||||
|
||||
**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.
|
||||
### Phase 9 — Discord: voice channels (`website` + `bot`)
|
||||
|
||||
**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`.
|
||||
`team_integrations`, the threshold gate, the shared category, overwrite management with role
|
||||
escalation above `voice_overwrite_max`, the grace-window lifecycle, and the stale-sync suspension.
|
||||
|
||||
### 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
|
||||
|
||||
1. **`PRIMARY KEY (platform, team_id)` cannot hold the default row.** MariaDB coerces every primary
|
||||
key column to `NOT NULL`, so `team_id NULL` — the deployment-wide default, and the base case of the
|
||||
whole override mechanism — is unrepresentable. As built: a surrogate `id`, a generated
|
||||
`team_key AS (IFNULL(team_id, 0)) STORED` in the unique key, and the foreign key the original DDL
|
||||
had no room for. Same idiom as `teams.active_key` and `content_reports.open_marker`.
|
||||
2. **The visibility gate has no data source on either side, and cannot have one.** §7.2 bridges an
|
||||
event only if "its `visibility` is `public`, or its destination channel is configured for a
|
||||
members-only Team context". The four `team.*` streams carry no visibility — only `team_activity`
|
||||
rows 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 behind
|
||||
`team_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 shape `teams_forum_uploads_ack` already 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.
|
||||
3. **"Identical to `announce` and `mod-reverse`" names two different things.** `announce` rides
|
||||
`announce_jobs` with backoff, retries and a per-leg retry button; `mod-reverse` is 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.
|
||||
4. **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.
|
||||
5. **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. `memberJoined`
|
||||
grew 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.
|
||||
|
||||
1. **Every query failed on a duplicate result column.** `desiredTeams` and `holdersWithoutClaim` both
|
||||
select `t.id AS team_id`, and the shared column list added `i.team_id` beside it — which the
|
||||
`mariadb` driver refuses outright ("Error in results, duplicate field name `team_id`"). The pass
|
||||
died at its first query, on the one code path every unit test stubs. It was also the wrong column:
|
||||
`desiredTeams` LEFT JOINs, so `i.team_id` is NULL for exactly the Teams that have no channel yet.
|
||||
2. **"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 `reconcileNow` does.
|
||||
|
||||
#### Two things outside this phase that it had to work around
|
||||
|
||||
- **`npm run swagger` could not run at all on `edge`.** 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.js` sits exactly at that
|
||||
parser's **per-file limit**: at twenty `teamsRouter.*` 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
|
||||
bare `use`. The voice routes are therefore their own router file, mounted from `admin/index.js`.
|
||||
- **`last_success_at` is written by MariaDB's `NOW()` and compared against JS `Date.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.
|
||||
### Phase 10 — the capability layer (`website` + `docs`)
|
||||
|
||||
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
|
||||
@@ -2969,35 +2543,6 @@ members and has never mentioned `registerNotificationStreams`, `registerAnnounce
|
||||
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 literal `uo.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 on `edge` (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
|
||||
> `registerTeamProvider` over 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 both `projectRoster` and 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>/members`
|
||||
> answered `projected: 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: `PageHeader` takes `lead`, not `subtitle`, 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
|
||||
|
||||
Reference in New Issue
Block a user