feat(teams): the reconciler, its four refusal gates, and ctx.teams (API 1.6.0)

Core's projection of the module's Teams, kept in step (docs/website/TEAMS.md
§2.4), plus the two ctx members a module pushes through.

The four gates are the file, and each is invariant 1 in a different costume --
module unavailability is staleness, never emptiness:

  1. getTeams() not ok           -> record the failure, touch NOTHING, return.
  2. ok but empty, core holds >=1 -> quarantine; apply only if the NEXT
                                    authoritative answer, an interval later,
                                    agrees.
  3. getTeamMembers() not ok      -> that Team's roster untouched and stale; the
                                    other Teams sync normally.
  4. ok but zero members, had some -> the same two-strikes quarantine, per Team.

Gates 2 and 4 exist because an authoritative-looking empty answer during a cold
start is the one failure indistinguishable from a real wipe. "Every Team on the
shard disbanded at once" costs one interval to confirm; getting it wrong empties
every roster on the site.

Events are an optimisation, never the source of truth. Member and leadership
deltas apply at once for a Team core already knows; team.created and
team.disbanded only ask for a run. §2.2 scopes archival to an authoritative full
list, so a repeated or spurious disband event costs a reconcile rather than a
Team -- and a Team invented from a delta would have no name, no roster and no
leaders anyway.

Two columns TEAMS.md did not contemplate, both on `teams`:

  - roster_synced_at, because team_sync_state holds one row per MODULE and gate 3
    leaves ONE Team behind while the others sync. Without a per-Team stamp that
    Team's page would report the module's last success as its own -- exactly the
    staleness the gate exists to surface.

  - members_empty_since, gate 4's per-Team quarantine. The twin of
    team_sync_state.pending_empty_since, which is per module and cannot express it.

One real bug found by its own test. The roster upsert was writing is_leader, so a
refused getTeamLeaders() left every member demoted -- the roster had already
written `leader: false` before the authoritative call was even made. §2.5 is
explicit that path 2 is answered by getTeamLeaders(), so is_leader is now set on
INSERT only (seeding a Team so it is not leaderless while that call fails) and
moved afterwards by setLeaders() alone. Two writers for one column was the whole
defect.

MODULE_API_VERSION 1.6.0 on both halves -- they state one contract and a module
declares one coreApi range. The number covers the whole Team surface per Part 11;
the members arrive by phase. registerTeamProvider, ctx.teams.publish and
ctx.teams.reconcile are live. ctx.teams.activity.push (§4, phase 3) and
api.registerSlashCommands (§7.1, phase 7) are present and THROW with a sentence
naming their phase, rather than being absent or silently accepting data into
tables that do not exist yet.

39 tests here, and the ctx surface guard in moduleLoader.test.js updated -- it
caught the addition, which is what it is for. Server 809 passed, client 192
passed, 0 failed.

Refs docs/website/TEAMS.md §2.2, §2.3, §2.4, Part 11, Part 12 phase 2

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 14:53:52 -05:00
parent 8b63ffc725
commit 92631347f9
10 changed files with 1650 additions and 3 deletions

View File

@@ -119,6 +119,7 @@ function buildCtx(id, moduleRoot) {
const uploads = require('../router/v1/admin/imageUpload')
const activity = require('../model/activity/activity.model')
const users = require('../model/users/users.model')
const teams = require('../model/teams/teamSync.model')
const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
/* eslint-enable global-require */
@@ -174,6 +175,31 @@ function buildCtx(id, moduleRoot) {
// a place nobody looks. `list` stays core's: reading the log is the admin
// panel's job, and it spans every actor.
activity: { log: activity.log },
// Teams (API 1.6.0, TEAMS.md §2.3). Push, to the pull the provider answers.
//
// Both are fire-and-forget by contract. `publish` is an OPTIMISATION — it
// makes a membership change visible at once — and `reconcile` is a REQUEST,
// debounced and never awaited, so a module cannot make its own call site slow
// or turn a background failure into its own error. Correctness comes from the
// reconciler either way; these only decide how soon.
//
// There is deliberately no reader here. A module answers questions about
// Teams; it does not ask them. Every Team table is core-internal (§10.3), and
// a `getTeamRoster` on ctx would be core offering to read back the module's
// own answer — which is the module's data, in the module's own store.
teams: {
publish: (event) => teams.publish(event),
reconcile: (opts) => teams.request(opts),
// §4's activity feed, which lands with the Team pages in phase 3. Declared
// in 1.6.0 alongside the rest of the Team surface; calling it before phase 3
// throws rather than silently accepting items into a table that does not
// exist yet.
activity: {
push: () => {
throw new Error('ctx.teams.activity.push is not available until the Team activity feed lands (TEAMS.md §4)')
},
},
},
// One function, for one caller: the `admin.users.detail` slot router needs
// the user its prefix names. Narrowed like `ctx.posts` — the users model
// exports creation, role changes and password handling, none of which is a
@@ -249,6 +275,14 @@ function buildApi(record) {
once('registerTeamProvider')
record.staged.registerTeamProvider(provider)
},
// Declared in 1.6.0 with the rest of the Team surface; the bot half that
// executes a command lands in phase 7 (§7.1). Present and throwing rather
// than absent, so a module written against the published version fails at
// registration with a sentence naming the phase, instead of at whatever
// moment someone first types the command.
registerSlashCommands() {
throw new Error('api.registerSlashCommands is not available until Discord slash commands land (TEAMS.md §7.1)')
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply