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

@@ -885,6 +885,16 @@ CREATE TABLE IF NOT EXISTS teams (
-- runs on every sync, and this is what stops it re-hiding a Team a human has
-- already allowed — without it the override would be undone every 15 minutes.
name_reviewed_at DATETIME NULL,
-- PER-TEAM freshness, which team_sync_state cannot express: it holds one row per
-- MODULE, and §2.4 gate 3 leaves one Team's roster untouched while the others
-- sync normally. Without a per-Team stamp that Team's page would claim the
-- module's last success as its own, which is precisely the staleness the rule
-- exists to surface. Bumped only when a roster is actually applied.
roster_synced_at DATETIME NULL,
-- §2.4 gate 4's per-Team quarantine, the twin of team_sync_state.pending_empty_
-- since: an authoritative-but-empty ROSTER for a Team that currently has members
-- is remembered here and applied only if the next answer agrees.
members_empty_since DATETIME NULL,
-- Staff may change what is DISPLAYED without touching identity (§2.8.3).
display_name_override VARCHAR(160) NULL,
-- The successor row written at archive time when this Team was renamed, so the