docs(teams): phase 6 as built — four deviations and a ninth contract member

Part 6 gains an as-built header rather than a rewrite, so the reasoning that
produced the original design stays legible beside what the build learned.

Four deviations. There was no web notification settings screen to add the Team
list to — `/auth/me/notifications/*` was built for the app in M7 and had zero web
consumers, which is survivable for push and not for a sink whose whole argument is
the web-only user. Email defaults to `off` rather than `digest`, on the org lead's
call: digest-by-default would start mailing every member of every Team the moment
an operator connects Gmail. Roster events tickle but do not email. And a ninth
member joined MODULE_API 1.6.0.

`pageUrlTemplate` is the member, and it exists because phase 3 left core with no
Team page and therefore no way to link to one. It joins 1.6.0 in place under the
rule set in phase 2 — a contract owes a bump only once it has landed on `main`,
and 1.6.0 has only ever been on `edge`.

Two further build decisions are recorded where they belong: the digest computes at
send time and keeps no queue (§6.4), and one-click unsubscribe is a stateless HMAC
whose whole capability is muting one (user, Team) pair (§6.4).

BACKEND_DESIGN gains the table, the two `/auth/me` routes and the unsubscribe
endpoint — the only write in the public tier and the only route with no `siteMode`,
because the mail went out before the site went into maintenance.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:49 -05:00
parent 04eb859a48
commit 5e284d5468
3 changed files with 132 additions and 16 deletions

View File

@@ -33,14 +33,21 @@ The client half carries the same number (`client/src/modules/version.js`) and a
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
module chunk evaluates, which is earlier than any network round trip could answer.
**1.6.0 — Teams, the whole surface.** Eight additions, no removals and no changed signature, so minor;
**1.6.0 — Teams, the whole surface.** Nine additions, no removals and no changed signature, so minor;
`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` · `api.registerSlashCommands(...)` ·
the provider's optional `projectRoster` and `pageUrlTemplate` · `api.registerSlashCommands(...)` ·
`registry.declareModuleSlot(...)` with `Slot` in the UI kit.
> **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,
> `pageUrlTemplate` on the team provider, joins 1.6.0 in place — same rule as the eighth below, and
> 1.6.0 is still `edge`-only. It is the one thing phase 6 found that the design of record had not
> anticipated: after phase 3 deleted core's Team pages, nothing in this contract could tell core where
> a Team page actually is, so a notification email could name a Team and not link to it. See
> `registerTeamProvider` below.
>
> **The eighth member joins 1.6.0 in place rather than getting a 1.7.0.** The rule is the one Protocol
> 4 was given in phase 2 — *a contract owes a bump only once it has landed on `main`* — and 1.6.0 has
> only ever been on `edge`. `ctx.teams.activity.push` is live now rather than throwing.
@@ -395,7 +402,8 @@ Before it existed, core's post controller required `utils/newsGump` directly —
naming a UO file, and the last thing binding core to the module.
**`registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })`** — added in API 1.6.0. The
module becomes the authoritative source of Teams for this deployment.
module becomes the authoritative source of Teams for this deployment. Two further members,
`projectRoster` and `pageUrlTemplate`, are optional and documented below.
**One provider per deployment.** Unlike every other registry, this holds a single value: Teams have
one authoritative source by construction, and two modules answering "what Teams exist" would produce
@@ -410,6 +418,8 @@ getTeamMembers(externalId) // (string) => Promise<{ ok, complete?, members }>
getTeamLeaders(externalId) // (string) => Promise<{ ok, leaders }> // leaders = [memberKey]
projectRoster(externalId, members, viewer) // OPTIONAL (1.6.0, phase 3)
// => Promise<{ ok, members }> // members = [memberKey]
pageUrlTemplate // OPTIONAL (1.6.0, phase 6) — DATA, not a method
// e.g. '/uo/guilds/{externalId}'
// authoritative
{ ok: true, complete: true, teams: [ { externalId, name, abbr?, meta? } ] }
@@ -447,6 +457,27 @@ Core distinguishes two refusals, and a module does not have to do anything to ge
serves an empty roster and says so in the response (`projected: false`,
`projectionUnavailable: true`).
**`pageUrlTemplate` is the fifth member, it is data rather than a method, and it exists because core
cannot link to a Team page.** Teams are a contract primitive with **no core surface** (TEAMS.md
Part 3): core owns the tables, the sync and the access rules, and the module that owns the vocabulary
owns the page. That is settled and right, and it leaves core unable to write the link a notification
email needs — an email about a forum reply that cannot take you to the thread is most of the way to
useless. So the module that owns the page says where it is.
```js
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders,
pageUrlTemplate: '/uo/guilds/{externalId}' })
```
Core substitutes `{externalId}` and `{slug}` and does nothing else with it. **A relative path only**
a template naming its own host is refused at registration, since there is no reason for a module to
redirect the site's outbound mail, and a protocol-relative `//host/x` is refused with it. Omitting the
member costs the deployment clickable links in Team notification email and nothing else.
**Data rather than a callback, deliberately.** A function here would put a module hook on the mail
path — one more thing that can hang or throw between a forum reply and the mail about it — to produce
a string that never varies.
Core hands over the roster rows it holds plus a described viewer — `{ userId, role }`, or `null` for
an anonymous caller — and never the `users` row, which would make every column of that table part of
this contract. **The module answers with member KEYS, not rows.** Core keeps ownership of what a
@@ -1201,19 +1232,23 @@ forced it is worth stating because it will recur:
```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 page, from the UI kit:
<Slot name="uo.guild.header" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.detail" externalId={guildId} moduleId="uo" />
<Slot name="uo.guild.forum" externalId={guildId} moduleId="uo" />
```
**A module declares one slot per PLACE, not one per page.** `module-uo` declares two on the same guild
page — core fills the first with the Team activity feed and the second with the Team forum — because a
slot holds one component and the first fill wins. Collapsing them into one would hand core the
decision about where each of its contributions sits, on a page the module owns. Two also keeps them
independent: a deployment with the forum switched off renders the feed unchanged.
**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
decision about where each of its contributions sits, on a page the module owns, and the module does
use that freedom: the notification control goes **above** the roster because muting is an action *on*
the page, and the other two go below it because they are content *in* it. Separate slots also keep
them independent: a deployment with the forum switched off renders the other two unchanged.
**The name must be namespaced under the declaring module's id**, and that is enforced rather than
conventional: it is the only thing keeping two modules from claiming one name, and it makes the owner