feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check #158

Merged
whitlocktech merged 1 commits from feature/teams-phase8-notifications-bridge into edge 2026-08-19 01:33:15 +00:00
Member

Phase 8 of docs/website/TEAMS.md. The same Team event as §6, delivered a third time: a content-free push tickle, an email, and now a Discord channel the operator configured. Not a second pipelineteamNotify.js already computed the recipient set once, so the bridge is a sink beside the two that were there, not a subscriber to something new.

Pairs with docs#162.

ONE code repo, not the plan's website + bot

bot is a workspace inside website — the same correction phase 7 made. Unlike phase 7, nothing here belongs to a module: the four streams are core's own and the bridge reads core's own forum, so module-uo is untouched and MODULE_API_VERSION does not move.

Org-lead decisions taken before building

  • The gate is a per-row acknowledgement, not a content-free bridge and not public-events-only.
  • One-shot, best-effort, not queued through announce_jobs.
  • The admin surface is its own panel under Admin → Teams, not an extension of the Discord Bot panel.

Five things the tree disagreed with §7.2 about

  1. The DDL cannot hold its own default row. PRIMARY KEY (platform, team_id) with team_id INT NULL: MariaDB coerces every primary-key column to NOT NULL, so the deployment-wide default — the base case of the whole override mechanism — is unrepresentable. Proved on a real MariaDB, which silently rewrote the column and then answered ERROR 1048 (23000): Column 'team_id' cannot be null. As built: a surrogate id, a generated team_key AS (IFNULL(team_id, 0)) STORED carrying the unique key, and the ON DELETE CASCADE foreign key the original had no room for — without it a deleted Team leaves its config for whichever Team next lands on the id. 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". But the four team.* streams carry no visibility (only team_activity rows do); forum threads have no public/members column, because a forum is members-only by construction with every thread behind team_forum_grants; and core cannot see a Discord channel's permissions. So §7.2's own example config, ['team.announcement','team.forum.post'], names exactly the two events that are never public.

    As built: an attributed operator acknowledgement (members_ack / _by / _at), in the shape teams_forum_uploads_ack already uses. Four properties make it a gate rather than a checkbox — it is a precondition (422, not a quiet drop at delivery, because a config 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; changing the channel clears it, since an acknowledgement is about a destination and cannot survive the destination changing underneath it; and a roster-only or disabled row needs none, because a dialog that appears on saves that did not need it is one people learn to click through.

  3. "Identical to announce and mod-reverse" names two different reliability models. 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 one arriving twenty minutes after the conversation moved on is worse than one that never arrives.

  4. 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.

  5. A roster event carries a count and never a name. 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. The tickle beside it is byte-identical, and a character name is game-sourced text screened for a page, not for a channel.

What is here

Server. team_integration_config + a model/db pair; utils/teamBridge.js as the sink; three routes on the admin Teams router, admin-only — the one such corner of a staff-wide router, because 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.

Bot. discord/teamNotify.js and POST /internal/team-notify. The channel comes from the app, not from guild_config the way announce finds #news: which channel a Team's events go to is per-Team configuration, and a bot that resolved it would hold a second copy of a table it cannot see the inputs to.

Client. A "Notification bridge" panel under Admin → Teams, with its logic in lib/teamIntegrations.js per the teamAdmin.js convention. Named for the Team concern rather than for Discord, so phase 10's capability refactor changes what fills it and not where it is.

The live rig

Real ServUO tree + the app with module-uo installed + a fake standing in for Discord, against a throwaway database rather than the shared review one (phase 7's note). Everything up to channel.send was production code, including the bot's real route and controller.

It proved: the schema as built holding a default row and two overrides under one unique key; the 422 on an unacknowledged forum bridge; the acknowledgement clearing on a repoint, with the stored row still pointing at the originally-vetted channel because a refused save writes nothing; a per-Team override switching the bridge OFF for one Team while the default stays on; the acknowledgement revoked underneath a row silencing it at the next post; forums switched off silencing every sink; and a real announcement arriving as an embed with module-uo's pageUrlTemplate link. The three failure modes stay distinguishable in the log — 503 (not connected), 400 (that id is not a text channel), status: 0 (bot process gone) — and every forum post returned 200 through all of them. A moderator gets 403 on the bridge and 200 on the staff-wide part of the same router.

It found both defects, via 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; discord.js rejects that outright, so an over-long forum post would not have arrived at all rather than arriving truncated.

Tests

1119 server (+35), 32 bot (+12), 263 client (+15). routes.manifest.json / routes.guards.json regenerated — three routes, whose guard shape is byte-identical to the existing admin-only ones. Swagger regenerated (+439 lines, nothing else touched).

Not done here

No real Discord guild was involved. channels.fetch against a live API and a real channel.send are the two things this walk could not exercise — the same gap phase 7 recorded for REST.put.


  • AI-assisted: Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz

Phase 8 of `docs/website/TEAMS.md`. The same Team event as §6, delivered a third time: a content-free push tickle, an email, and now a Discord channel the operator configured. **Not a second pipeline** — `teamNotify.js` already computed the recipient set once, so the bridge is a sink beside the two that were there, not a subscriber to something new. Pairs with **docs#162**. ## ONE code repo, not the plan's `website` + `bot` `bot` is a workspace inside `website` — the same correction phase 7 made. Unlike phase 7, nothing here belongs to a module: the four streams are core's own and the bridge reads core's own forum, so `module-uo` is untouched and `MODULE_API_VERSION` does not move. ## Org-lead decisions taken before building - **The gate is a per-row acknowledgement**, not a content-free bridge and not public-events-only. - **One-shot, best-effort**, not queued through `announce_jobs`. - **The admin surface is its own panel under Admin → Teams**, not an extension of the Discord Bot panel. ## Five things the tree disagreed with §7.2 about 1. **The DDL cannot hold its own default row.** `PRIMARY KEY (platform, team_id)` with `team_id INT NULL`: MariaDB coerces every primary-key column to `NOT NULL`, so the deployment-wide default — the base case of the whole override mechanism — is unrepresentable. **Proved on a real MariaDB**, which silently rewrote the column and then answered `ERROR 1048 (23000): Column 'team_id' cannot be null`. As built: a surrogate `id`, a generated `team_key AS (IFNULL(team_id, 0)) STORED` carrying the unique key, and the `ON DELETE CASCADE` foreign key the original had no room for — without it a deleted Team leaves its config for whichever Team next lands on the id. 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". But the four `team.*` streams carry no visibility (only `team_activity` rows do); forum threads have no public/members column, because a forum is members-only *by construction* with every thread behind `team_forum_grants`; and core cannot see a Discord channel's permissions. So §7.2's own example config, `['team.announcement','team.forum.post']`, names exactly the two events that are never public. **As built: an attributed operator acknowledgement** (`members_ack` / `_by` / `_at`), in the shape `teams_forum_uploads_ack` already uses. Four properties make it a gate rather than a checkbox — it is a **precondition** (422, not a quiet drop at delivery, because a config 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; **changing the channel clears it**, since an acknowledgement is about a destination and cannot survive the destination changing underneath it; and a roster-only or *disabled* row needs none, because a dialog that appears on saves that did not need it is one people learn to click through. 3. **"Identical to `announce` and `mod-reverse`" names two different reliability models.** `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 one arriving twenty minutes after the conversation moved on is worse than one that never arrives. 4. **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. 5. **A roster event carries a count and never a name.** `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. The tickle beside it is byte-identical, and a character name is game-sourced text screened for a *page*, not for a channel. ## What is here **Server.** `team_integration_config` + a model/db pair; `utils/teamBridge.js` as the sink; three routes on the admin Teams router, **admin-only** — the one such corner of a staff-wide router, because 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. **Bot.** `discord/teamNotify.js` and `POST /internal/team-notify`. The channel comes **from the app**, not from `guild_config` the way `announce` finds `#news`: which channel a Team's events go to is per-Team configuration, and a bot that resolved it would hold a second copy of a table it cannot see the inputs to. **Client.** A "Notification bridge" panel under Admin → Teams, with its logic in `lib/teamIntegrations.js` per the `teamAdmin.js` convention. Named for the Team concern rather than for Discord, so phase 10's capability refactor changes what fills it and not where it is. ## The live rig Real ServUO tree + the app with module-uo installed + a fake standing in for Discord, against a **throwaway database** rather than the shared review one (phase 7's note). Everything up to `channel.send` was production code, including the bot's real route and controller. It proved: the schema as built holding a default row and two overrides under one unique key; the 422 on an unacknowledged forum bridge; the acknowledgement clearing on a repoint, with the stored row still pointing at the originally-vetted channel because a refused save writes nothing; a per-Team override switching the bridge OFF for one Team while the default stays on; the acknowledgement revoked underneath a row silencing it at the next post; forums switched off silencing every sink; and a real announcement arriving as an embed with module-uo's `pageUrlTemplate` link. The three failure modes stay distinguishable in the log — `503` (not connected), `400` (that id is not a text channel), `status: 0` (bot process gone) — and **every forum post returned 200 through all of them**. A moderator gets 403 on the bridge and 200 on the staff-wide part of the same router. **It found both defects, via 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; discord.js rejects that outright, so an over-long forum post would not have arrived at all rather than arriving truncated. ## Tests 1119 server (+35), 32 bot (+12), 263 client (+15). `routes.manifest.json` / `routes.guards.json` regenerated — three routes, whose guard shape is byte-identical to the existing admin-only ones. Swagger regenerated (+439 lines, nothing else touched). ## Not done here No real Discord guild was involved. `channels.fetch` against a live API and a real `channel.send` are the two things this walk could not exercise — the same gap phase 7 recorded for `REST.put`. --- - [x] AI-assisted: Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WnDSWzpUjw8t8C2hghysNz
wtclaude added 1 commit 2026-08-19 01:26:40 +00:00
feat(teams): phase 8 — the notifications bridge, and the gate §7.2 could not check
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Successful in 10m49s
11b4368b57
The same Team event as §6, delivered a third time: push, email, and now a
Discord channel the operator configured. Not a second pipeline — teamNotify.js
already computed the recipient set once, so the bridge is a sink beside the two
that were there.

The design's gate has no data source. §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; forum
threads have no public/members column because a forum is members-only by
construction; and core cannot see a Discord channel's permissions. So §7.2's own
example config names exactly the two events that are never public.

The gate is therefore an attributed operator acknowledgement, in the shape
teams_forum_uploads_ack already uses. It is a precondition — 422, not a quiet
drop at delivery — it is re-asked at delivery as well as at the save, and
changing the channel clears it, because an acknowledgement is about a
destination and cannot survive the destination changing underneath it.

The design's DDL cannot hold its own default row: MariaDB coerces every PRIMARY
KEY column to NOT NULL, so `team_id NULL` — the deployment-wide default every
override overrides — is unrepresentable. Proved on a real MariaDB (error 1048).
Replaced with a surrogate id, a generated team_key AS IFNULL(team_id, 0) in the
unique key, and the foreign key the original had no room for.

One-shot, not queued: "identical to announce and mod-reverse" names two
different reliability models, and a Team notification is the moment it
describes.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit d1d56cf847 into edge 2026-08-19 01:33:15 +00:00
whitlocktech deleted branch feature/teams-phase8-notifications-bridge 2026-08-19 01:33:16 +00:00
Sign in to join this conversation.
No description provided.