From 225663d62e65183fac43e5f314e32b3eb30b4a77 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 17 Aug 2026 14:40:26 -0500 Subject: [PATCH] feat(teams): core schema for Teams, membership, sync state and moderation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six core tables Team core is built on (docs/website/TEAMS.md §2.1, §2.5, §2.5.1, §2.9), plus the §2.10 account-deletion decisions expressed as foreign keys rather than left to whatever the defaults happened to be. Every table is core-internal (§10.3): a module populates them through the team provider and must never read or write one directly. They carry no _ prefix, correctly -- MODULE_API.md §2.6's prefix rule binds modules, and these are core's. team_forum_grants lands in this phase rather than in phase 4, so the four-path resolver is written once and its non-contamination tests are real. Nothing writes it yet; the grant/revoke flow, the per-Team cap and the leader UI are phase 4's. Two departures from the SQL as TEAMS.md sketched it, both recorded in the file: - team_forum_grants.user_id is nullable with ON DELETE SET NULL, following §2.10 (the audit trail of who granted whom must survive the account) rather than §2.5's CASCADE. - its uniqueness marker is derived from revoked_at alone, with user_id moved into the unique KEY. §2.5's `active_user AS (IF(revoked_at IS NULL, user_id, NULL))` cannot coexist with the line above: MariaDB refuses ON DELETE SET NULL on a foreign key whose column is a base column of a STORED generated column (error 1901). The semantics are identical -- at most one active grant per (team, user), unlimited revoked rows. Verified by running ensureSchema() against MariaDB 11: all six tables create, both generated columns materialise, and every foreign key's delete rule matches §2.10's table. The uniqueness encoding was checked directly -- a second active grant for the same (team, user) is rejected 1062 while revoked rows accumulate freely. Refs docs/website/TEAMS.md Part 12 phase 2 Co-Authored-By: Claude --- server/db/schema.sql | 196 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/server/db/schema.sql b/server/db/schema.sql index 3a6c6e5..f12fc68 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -845,6 +845,202 @@ CREATE TABLE IF NOT EXISTS installed_modules ( INDEX idx_installed_modules_state (state) ) 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 `_` 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 -- 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.