feat(teams): Teams as a platform primitive — MODULE_API 1.6.0 (Teams cutover 4/6) #161

Merged
whitlocktech merged 45 commits from edge into main 2026-08-19 08:57:13 +00:00
Showing only changes of commit 225663d62e - Show all commits

View File

@@ -845,6 +845,202 @@ CREATE TABLE IF NOT EXISTS installed_modules (
INDEX idx_installed_modules_state (state) INDEX idx_installed_modules_state (state)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Teams (docs/website/TEAMS.md Part 2, phase 2) ─────────────────────────────
--
-- A Team is a core platform entity POPULATED by a module and owned by core. The
-- module answers "what teams exist and who is in them" through the team provider
-- (MODULE_API.md — registerTeamProvider); core stores the answer, gates it and
-- displays it. Every table below is core-internal (TEAMS.md §10.3): a module must
-- never read or write one, even though a module is what fills them.
--
-- Note the tables carry no `<moduleId>_` prefix, correctly — MODULE_API.md §2.6's
-- prefix rule binds modules, and these are core's.
-- The Team itself. `external_id` is the module's own stable identity for it
-- (module-uo sends the persistent ServUO Guild.Id) and is opaque to core.
--
-- `name` is IMMUTABLE for the life of the row (§2.2): a rename archives this row
-- with archived_reason='renamed' and creates a new one, so the old Team keeps its
-- activity, its grants and its forum as a read-only record. What staff can change
-- is display_name_override, which changes what is RENDERED and never what the row
-- IS — identity and display are different things and only identity is frozen.
CREATE TABLE IF NOT EXISTS teams (
id INT AUTO_INCREMENT PRIMARY KEY,
module_id VARCHAR(32) NOT NULL, -- which module is authoritative
external_id VARCHAR(191) NOT NULL, -- opaque to core
name VARCHAR(160) NOT NULL,
abbr VARCHAR(32) NULL,
slug VARCHAR(191) NOT NULL, -- derived from name, unique among ACTIVE teams
status ENUM('active','archived') NOT NULL DEFAULT 'active',
meta JSON NULL, -- module-supplied, opaque (alliance, crest, …)
member_count INT NOT NULL DEFAULT 0, -- denormalised from team_members
linked_count INT NOT NULL DEFAULT 0, -- members whose user_id is not null
online_count INT NOT NULL DEFAULT 0, -- last known; refreshed by sync
-- Public suppression, independent of status. A hidden Team still works
-- completely for its own members; it is absent from public surfaces (§2.8).
hidden TINYINT(1) NOT NULL DEFAULT 0,
hidden_reason ENUM('reserved_name','staff') NULL,
hidden_term VARCHAR(64) NULL, -- which reserved term matched, for the review queue
-- Set once staff have made an explicit decision about the name. Re-screening
-- 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,
-- 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
-- old slug can still resolve and explain itself rather than 404 (§2.2).
succeeded_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL,
archived_reason VARCHAR(64) NULL, -- 'disbanded' | 'renamed' | 'staff'
-- A generated column is how "unique among ACTIVE rows only" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key, so
-- any number of archived rows may share an external_id.
active_key VARCHAR(191) AS (IF(status='active', external_id, NULL)) STORED,
active_slug VARCHAR(191) AS (IF(status='active', slug, NULL)) STORED,
UNIQUE KEY uq_teams_active (module_id, active_key),
UNIQUE KEY uq_teams_active_slug (active_slug),
INDEX idx_teams_status (status),
INDEX idx_teams_slug (slug),
INDEX idx_teams_review (hidden, hidden_reason),
-- Self-referential and deliberately SET NULL: a successor may itself be archived
-- and eventually pruned, and losing the pointer must not take the old row with it.
CONSTRAINT fk_teams_succeeded_by FOREIGN KEY (succeeded_by) REFERENCES teams(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The membership PROJECTION. Module-authoritative; core only mirrors it, and the
-- sync is the ONLY writer (§2.5 path 1). Rows are soft-departed rather than
-- deleted so history and rejoin detection survive, and so the activity feed can
-- still name a departed member.
--
-- user_id is resolved BY THE MODULE (it owns the game↔site link table); core never
-- resolves it, because doing so would be core reading a module's table by name.
CREATE TABLE IF NOT EXISTS team_members (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL, -- module's stable member id (UO: character serial)
display_name VARCHAR(160) NULL, -- in-game name
user_id INT NULL, -- resolved by the MODULE; NULL = unlinked
is_leader TINYINT(1) NOT NULL DEFAULT 0,
rank_label VARCHAR(48) NULL, -- module vocabulary, opaque to core
online TINYINT(1) NOT NULL DEFAULT 0,
status ENUM('active','departed') NOT NULL DEFAULT 'active',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
departed_at DATETIME NULL,
PRIMARY KEY (team_id, member_key),
-- SET NULL, not CASCADE (§2.10): deleting a site account does not remove the
-- character from the guild — only the link to the site goes.
CONSTRAINT fk_team_members_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_team_members_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_team_members_user (user_id),
INDEX idx_team_members_status (team_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Freshness of the module's answer. One row per module. THE table invariant 1
-- ("module unavailability is staleness, never emptiness") is enforced against.
CREATE TABLE IF NOT EXISTS team_sync_state (
module_id VARCHAR(32) NOT NULL PRIMARY KEY,
last_attempt_at DATETIME NULL,
last_success_at DATETIME NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
last_error VARCHAR(500) NULL,
-- The quarantine for §2.4's mass-deletion guard: an authoritative-but-empty
-- answer is remembered here and applied only if the NEXT one agrees.
pending_empty_since DATETIME NULL,
INDEX idx_team_sync_success (last_success_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Staff leadership overrides (§2.5.1), applied ON TOP of the synced value at read
-- time. The projection is never mutated: the sync keeps writing what the game
-- says and this keeps saying what staff decided, which is the whole point — an
-- override the sync clobbered every 15 minutes would be useless.
CREATE TABLE IF NOT EXISTS team_leader_overrides (
team_id INT NOT NULL,
member_key VARCHAR(191) NOT NULL,
effect ENUM('grant','deny') NOT NULL,
actor_user_id INT NULL,
actor_username VARCHAR(32) NULL, -- snapshot, so the record survives the account
reason VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (team_id, member_key),
CONSTRAINT fk_tlo_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tlo_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Forum access grants (§2.5 path 3) — an append-only grant/revoke ledger that is
-- ALSO the current state. An active grant is one with revoked_at IS NULL, and a
-- generated column is how "one active grant per (team,user)" is expressed without
-- a partial index (MariaDB has none): NULL never collides in a UNIQUE key.
--
-- The table lands here, in the phase that builds the resolver, so forumAccess() is
-- written once and its non-contamination tests are real. The grant/revoke FLOW,
-- the per-Team cap and the leader UI are phase 4's; nothing writes this table yet.
--
-- user_id is NULLABLE and SET NULL, which contradicts the sketch in TEAMS.md §2.5
-- and follows §2.10, which settled it deliberately: CASCADE would delete the audit
-- trail of who granted whom, which is exactly what an audit exists to survive. The
-- username snapshots keep the record readable after the account is gone.
--
-- THE TWO CANNOT BOTH BE HAD AS §2.5 WROTE THEM, and this is why the marker below
-- is a bare flag rather than §2.5's `active_user AS (IF(revoked_at IS NULL,
-- user_id, NULL))`. MariaDB refuses `ON DELETE SET NULL` on a foreign key whose
-- column is a base column of a STORED generated column (ER_GENERATED_COLUMN_
-- FUNCTION_IS_NOT_ALLOWED, 1901) — so §2.5's generated column forces §2.10's
-- CASCADE, and the audit trail with it. Deriving the marker from `revoked_at`
-- ALONE and putting user_id in the KEY instead gives identical semantics: at most
-- one active row per (team_id, user_id), unlimited revoked rows, and user_id free
-- to be a SET NULL foreign key. Verified against MariaDB 11 both ways.
CREATE TABLE IF NOT EXISTS team_forum_grants (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
user_id INT NULL,
username VARCHAR(32) NULL, -- snapshot of the grantee at grant time
granted_by INT NULL, -- NULL for a system grant, or a deleted actor
granted_username VARCHAR(32) NULL, -- snapshot of the actor
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255) NULL,
revoked_by INT NULL,
revoked_username VARCHAR(32) NULL,
revoked_at DATETIME NULL,
revoke_reason VARCHAR(255) NULL,
active_marker TINYINT(1) AS (IF(revoked_at IS NULL, 1, NULL)) STORED,
UNIQUE KEY uq_team_forum_grant_active (team_id, user_id, active_marker),
CONSTRAINT fk_tfg_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tfg_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_granted_by FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tfg_revoked_by FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tfg_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a
-- moderator asked to publish this name and an admin refused" is the record worth
-- having.
--
-- `action` + `payload` means a fourth gated action is an enum value rather than a
-- schema change. That is room to extend, not an invitation: nothing else is gated
-- today, and nothing should be without asking §2.9's question first.
CREATE TABLE IF NOT EXISTS team_moderation_requests (
id INT AUTO_INCREMENT PRIMARY KEY,
team_id INT NOT NULL,
action ENUM('unhide','display_name_override','clear_display_name_override') NOT NULL,
payload JSON NULL, -- e.g. { "displayName": "…" }
reason VARCHAR(255) NULL,
requested_by INT NULL,
requested_username VARCHAR(32) NULL, -- snapshot (§2.10)
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status ENUM('pending','approved','rejected','withdrawn') NOT NULL DEFAULT 'pending',
decided_by INT NULL,
decided_username VARCHAR(32) NULL,
decided_at DATETIME NULL,
decision_note VARCHAR(255) NULL,
CONSTRAINT fk_tmr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
CONSTRAINT fk_tmr_requested_by FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_tmr_decided_by FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_tmr_queue (status, requested_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Migrations for databases created before the wiki upgrade. Each statement uses -- Migrations for databases created before the wiki upgrade. Each statement uses
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
-- these columns from the CREATE TABLE above; existing installs get them here. -- these columns from the CREATE TABLE above; existing installs get them here.