|
|
|
|
@@ -100,11 +100,14 @@ 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`), 4s timeout,
|
|
|
|
|
never throws, always returns `{ ok, status, data, error }`.
|
|
|
|
|
(`/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-config` on the app's *unpublished*
|
|
|
|
|
internal listener (`server/src/internalApp.js`), with a retry-with-backoff bootstrap so a bot
|
|
|
|
|
restart self-heals.
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
@@ -827,18 +830,20 @@ 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)` — a **module** declares a place on its own page, namespaced
|
|
|
|
|
> under its own id, and **core** fills it:
|
|
|
|
|
> `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.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 }` |
|
|
|
|
|
> | `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 }` |
|
|
|
|
|
>
|
|
|
|
|
> 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,
|
|
|
|
|
> 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,
|
|
|
|
|
> which matters here because the thing being contained is CORE's content failing inside the MODULE's
|
|
|
|
|
> page.
|
|
|
|
|
>
|
|
|
|
|
@@ -1669,6 +1674,42 @@ 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
|
|
|
|
|
@@ -1737,10 +1778,20 @@ 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'
|
|
|
|
|
@@ -1753,10 +1804,33 @@ 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 — phase 10 makes the platform a registry lookup, and what should
|
|
|
|
|
> change then is what fills the panel, not where it is. 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
|
|
|
|
|
@@ -1764,11 +1838,76 @@ 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
|
|
|
|
|
@@ -1778,10 +1917,58 @@ 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
|
|
|
|
|
@@ -1797,34 +1984,83 @@ 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,
|
|
|
|
|
platform VARCHAR(32) NOT NULL, -- 'discord'
|
|
|
|
|
resource VARCHAR(32) NOT NULL, -- 'voice'
|
|
|
|
|
external_ref VARCHAR(64) NULL, -- the channel id
|
|
|
|
|
mode ENUM('overwrites','role') NOT NULL DEFAULT 'overwrites',
|
|
|
|
|
role_ref VARCHAR(64) NULL,
|
|
|
|
|
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** — `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
|
|
|
|
|
|
|
|
|
|
@@ -2301,13 +2537,16 @@ 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)`: 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.
|
|
|
|
|
> `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.
|
|
|
|
|
>
|
|
|
|
|
> **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
|
|
|
|
|
@@ -2485,23 +2724,173 @@ 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` + `bot` + `docs`)
|
|
|
|
|
### 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 `/team` as the first
|
|
|
|
|
command through it.
|
|
|
|
|
defer→dispatch→edit path, the actor resolver, the version-bump re-register, and the first command
|
|
|
|
|
through it.
|
|
|
|
|
|
|
|
|
|
**Ships:** a working `/team`, and the seam a module needs for its own commands.
|
|
|
|
|
**Ships:** a working `/guild`, and the seam a module needs for its own commands.
|
|
|
|
|
|
|
|
|
|
### Phase 8 — Discord: notifications bridge (`website` + `bot`)
|
|
|
|
|
**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)).
|
|
|
|
|
|
|
|
|
|
`team_integration_config`, the internal fan-out with push and bridge as two consumers,
|
|
|
|
|
`POST /internal/team-notify`, the admin per-event configuration.
|
|
|
|
|
**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.
|
|
|
|
|
|
|
|
|
|
### Phase 9 — Discord: voice channels (`website` + `bot`)
|
|
|
|
|
**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.
|
|
|
|
|
|
|
|
|
|
`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.
|
|
|
|
|
**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
|
|
|
|
|
|
|
|
|
|
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`. Phase 10 replaces "Discord" with whatever the capability registry declares; what
|
|
|
|
|
should change then is what fills the panel, not where an operator goes 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`)
|
|
|
|
|
|
|
|
|
|
@@ -2543,6 +2932,35 @@ 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
|
|
|
|
|
|