The kit's README sends a reader here FIRST - it is the shortest honest picture of
the whole job - and it predated Teams, so it taught a second game to build its
teams as private module data and never mentioned the provider. The two places the
contract changed since it was written are now in it, and nothing else moved:
all four findings stand, including the identity gap, which is still the one a
real second module hits first.
What Rust adds that UO does not, and why it was worth revisiting rather than
noting:
- externalId must survive a rename and a Rust team HAS no name - it is a numeric
team id in the save. The right answer, and the one a designer is least likely
to reach for.
- `complete` is per SERVER, not per community. Six servers are six team spaces,
so a provider that can reach five must leave `complete` off or core archives
every team on the sixth.
- A wipe empties every team, so { ok: true, complete: true, teams: [] } is TRUE
once a month and core archiving all of them is correct - which is exactly why
an unreachable RCON must answer { ok: false } instead. The two states are one
API call apart and only the module can tell them apart.
- The team route carries a server id as well as a team id, so the external id is
<serverId>:<teamId>. Core stores that and never parses it; an external id is
opaque to core by design, and this is the case that shows why.
Also: rust_teams stays the module's table and core's teams stays core's, which is
the boundary worth stating in the one document where both appear; and the kit is
nine members now, not seven.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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>
The org lead cancelled the capability layer on 2026-08-19, deferring it until a
second integration is wanted or it is asked for by name. Phase 11 is now the last
phase of the bet.
The same argument that put phase 10 last is the argument for not doing it yet:
with one integration built, the refactor would extract a capability surface from a
single implementation and have nothing to check the extraction against. It is
cheaper and better-informed the day a second platform exists, because that
platform is what proves which of the five capabilities the seam needs.
Three places pointed forward at it and now say what is true instead:
- Sec 7.2 and Sec 7.3 both justify the Admin -> Teams panels by "phase 10 makes
the platform a registry lookup". The decision survives its reason: an operator
should not have to know which platform is configured to find the panel, and that
holds whether or not the registry is ever built.
- Sec 8.2 keeps the Matrix comparison and the capability table, with a note that
no registry is built either. The research did its job by keeping core's calls
phrased as eligibility questions rather than as Discord operations; what is
absent is the indirection, so `discord` is named directly in the bridge, the
voice provisioner and the command dispatcher.
The phase entry keeps its body rather than deleting it, because the argument for
the layer is what a future phase would start from.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
Amends §7.2 inline and marks phase 8 done in Part 12; adds the
team_integration_config row to BACKEND_DESIGN.md's schema table.
Two of the amendments are things the tree disproved rather than choices:
- §7.2's DDL cannot hold its own default row. MariaDB coerces PRIMARY KEY
columns to NOT NULL, so `team_id NULL` is unrepresentable and the override
mechanism has no base case. Confirmed against a real MariaDB (error 1048).
- §7.2's visibility gate has no data source on either side and cannot have one:
the streams carry no visibility, a forum thread is members-only by
construction rather than by a column, and core cannot see a channel's
permissions. The gate becomes an attributed operator acknowledgement.
Co-Authored-By: Claude <noreply@anthropic.com>
The command that proves the seam is the MODULE's `/guild`, not core's `/team`:
§7.1 was written before phase 3 settled that Teams is a contract primitive with
no core surface, and a core `/team` publishes the same invented noun that got
core's Team pages deleted. Its deep link comes from `pageUrlTemplate` for the
same reason — `/teams/:slug` does not exist.
The re-register nudge is its own bot endpoint rather than a ride on
`/internal/config`, whose body carries the decrypted bot token. `actor` carries
`role` beside `isStaff`, since a module with its own audience rungs cannot place
a caller from a boolean. And "deregistration is free" needed a second half: it
holds across the restart an uninstall asks for, not across the runtime toggle,
so liveness is asked at both the pull and the dispatch.
MODULE_API.md stops saying `registerSlashCommands` throws and documents it —
every member of 1.6.0 is live now.
Co-Authored-By: Claude <noreply@anthropic.com>
The kit is the instruction book for putting a different game on this platform,
written for an audience outside this org. Teams expands the contract that book
teaches against, so the book is the last thing the bet owes before `edge` becomes
`main` (org lead, 2026-08-18).
One sentence in it is already wrong rather than merely incomplete.
`book/02-website-module.md` tells a reader that core declares a slot and a module
may only fill one. Phase 3 inverted exactly that, and by phase 6 module-uo declares
three — a new game's module cannot implement Teams at all without the inverted
direction.
Two shapes are genuinely new and worth teaching: the inverted slot, and
`registerTeamProvider` as the first registration where core calls the module and
waits — with the asymmetry that every call fails stale except `projectRoster`,
which fails closed, because for a visibility question "keep what you have" means
serving the roster unprojected.
The phase explicitly does NOT enumerate the contract. The kit already teaches four
members and has never mentioned notification streams, announce legs or post hooks,
all of which predate Teams. MODULE_API.md is normative; the kit teaches one path
and links out.
Its ordering is awkward and is stated rather than smoothed over: it is written
before the cutover and can only merge after it, because CI clones the pinned sha
and checks the template against that core's MODULE_API_VERSION — and 1.6.0 does
not reach `main` until the cutover lands.
Co-Authored-By: Claude <noreply@anthropic.com>
| `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`,
@@ -674,7 +676,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.**166 public routes + 2 on 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.**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/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
**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
@@ -1256,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
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,34 @@ 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
@@ -1764,11 +1839,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
plus the template's own `npm ci` / `check:imports` / `build` / `check:externals` / `npm test` on both
halves. Build the client **before** the client tests; two of them read the built chunk.
### Cross-cutting, every phase that touches the server
`npm run swagger` regenerated and committed · `npm run routes:manifest -- --check` zero-line diff ·
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.