Compare commits

...

4 Commits

Author SHA1 Message Date
2adf397da4 docs(teams): phase 11 as built, and what the rig walk found
The phase ran before the cutover as Part 12 planned, and found what it was meant
to: the inverted slot direction worked for module-uo and nobody else. Recorded
beside the phase entry, with the org lead's two decisions of the day - core
offers a contribution rather than naming a slot, and the template grows a real
provider rather than a snippet.

Also records the live-rig walk and the one defect it found that no test could:
PageHeader takes `lead`, not `subtitle`, and React drops an unknown prop in
silence, so every page built from the kit's template had been rendering its
heading with nothing under it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:31:52 -05:00
d10f5809b3 docs(modules): core offers a contribution, never a slot name
Amends MODULE_API 1.6.0 in place - it has only ever been on edge, the same rule
the eighth and ninth members were given - and it is a correction rather than an
addition.

As first written, the inverted slot direction had core fill three literal
uo.guild.* names. That worked for module-uo and silently did nothing for anyone
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 string literals the Sec 5.2 checker masks by construction.

Sec 3.7a now documents declareModuleSlot(id, name, { core }) and the three
contributions core offers - team.activity, team.forum, team.notify - as a
table, with the rules that follow from the direction: the member is optional, a
slot that asks for nothing stays empty, more than one slot may ask for the same
contribution, and asking for one core does not offer THROWS at the declaration
rather than rendering empty forever.

TEAMS.md's two accounts of the inversion (Part 3's supersession note and the
phase 3 amendment) say the same thing.

Also corrects the UI kit's count in Sec 3.4 and Sec 3.7a: Slot made it nine in
phase 3 and three places still said eight.

Found by phase 11 while writing the chapter that teaches this shape to an
audience outside this org.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 01:16:07 -05:00
6f1f406fe1 Merge pull request 'docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist' (#163) from docs/teams-phase9-voice into edge
Reviewed-on: #163
2026-08-19 05:15:43 +00:00
c87034d7fe docs(teams): phase 9 as built — roles, not overwrites, and three things §7.3 named that do not exist
Amends `TEAMS.md` §7.3 inline, marks phase 9 done in Part 12, and adds the
`team_integrations` row to `BACKEND_DESIGN.md`'s schema table. Pairs with
**website#159**.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 00:10:18 -05:00
3 changed files with 281 additions and 37 deletions

View File

@@ -554,6 +554,7 @@ core's.
| `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`,

View File

@@ -37,8 +37,16 @@ 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.
`registry.declareModuleSlot(...)` with `Slot` in the UI kit — the ninth member of it.
> **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,
@@ -56,8 +64,8 @@ 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)` 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, { 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.
**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
@@ -1050,7 +1058,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 **eight exports** — five rows, because `PageState` contributes three. An earlier
The kit is those **nine exports** — six 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
@@ -1293,10 +1301,11 @@ 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:
registry.declareModuleSlot(ID, 'uo.guild.header')
registry.declareModuleSlot(ID, 'uo.guild.detail')
registry.declareModuleSlot(ID, 'uo.guild.forum')
// 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 page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
@@ -1304,6 +1313,33 @@ registry.declareModuleSlot(ID, 'uo.guild.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
@@ -1318,25 +1354,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 (`fillModuleSlot`, core-only) and
these the slot does not exist. Core registers its intent (`offerCoreFill`, core-only) and
`applyCoreFills()` runs once, from `main.jsx`, after every chunk has evaluated and before the first
render.
**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
**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
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 eighth member of the UI kit** (§3.4) for this. A module could not render one of these
**`Slot` is the ninth 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. `fillModuleSlot` and
`applyCoreFills` are not: filling one of these is core's, exactly as declaring a §3.7 slot is.
`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.
---

View File

@@ -830,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.
>
@@ -1877,9 +1879,35 @@ Two smaller consequences of the same asymmetry:
### 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
@@ -1889,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
@@ -1908,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
@@ -2412,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
@@ -2709,10 +2837,60 @@ over-long forum post would not have arrived at all rather than arriving truncate
**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` + `bot`)
### Phase 9 — Discord: voice channels (`website`) — **DONE 2026-08-19**
`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.
`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`)
@@ -2754,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