-- Runic Gateway database schema (MariaDB) -- Run automatically by the MariaDB container (docker-entrypoint-initdb.d) on a -- fresh volume, and idempotently by ensureSchema() on every server boot. -- -- CORE ONLY. The 27 shard_* / uo_link_* tables left with module-uo in Phase 3 -- and live in its schema fragment, which core replays immediately after this -- file (MODULE_API.md 2.6). Two of them carry a foreign key INTO users, which -- is why that order matters and why the reverse -- a core table referencing a -- module table -- must never appear here: it would make core unable to boot -- without a module installed. CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, -- COLLATE is pinned to a case-insensitive (_ci) collation so uniqueness and -- findByUsername lookups both fold case identically ('Foo' == 'foo'). This is -- the atomic backstop for the username-uniqueness race (see the register / -- change-username duplicate-key handling). username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci UNIQUE, -- Nullable: SSO-provisioned players have no password until they choose to set -- one. A NULL hash means password login is impossible for that account -- (validatePassword returns false). password_hash VARCHAR(72) NULL, role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin', -- The account's ONE contact address, and the destination for password-reset -- mail. Unique since engagement Phase 1b — but the index is on email_norm -- below, never on this column, and the reason is not stylistic: -- -- Every case-insensitive (_ci) collation this server offers is ALSO -- accent-insensitive, so a UNIQUE index on `email` would refuse -- jose@x.com once josé@x.com exists. Those are two different mailboxes. -- -- LOWER() under a _bin collation folds case WITHOUT folding accents, which is -- exactly the equivalence a mail system uses. Keeping the fold in a generated -- column rather than in application code means it cannot be bypassed by a -- caller that forgets to normalize. email VARCHAR(255) NULL, -- The uniqueness key. STORED (not VIRTUAL) because a UNIQUE index over it must -- be materialized. Multiple NULLs are legal under a UNIQUE index, which is what -- lets the Phase 1b de-duplication null the losers without deleting an account. email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED, email_verified TINYINT(1) NOT NULL DEFAULT 0, -- An address the user has asked for but not yet proved. It does NOT displace -- `email` until the verification link is used, so a typo cannot silently -- redirect this account's password-reset mail. Deliberately NOT unique: a -- pending address reserves nothing, and two users may both be pending on one -- address — the second to verify loses, with the same generic failure. email_pending VARCHAR(255) NULL, -- Account lifecycle, independent of role: staff can disable/ban a player -- without changing their role. active = normal; disabled = admin-locked; -- banned = moderation ban; pending = reserved for future email-verify gating. -- Enforced in requireAuth + login (non-active is rejected). status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active', totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA) totp_enabled TINYINT(1) NOT NULL DEFAULT 0, -- Any session token issued before this instant is rejected (see requireAuth). -- Bumped on password change / "log out everywhere". NULL = no cutoff yet. tokens_valid_after DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at DATETIME NULL, last_login_ip VARCHAR(45) NULL, -- IPv6-capable, set on each login -- One account per mailbox (engagement Phase 1b). On the generated column, not -- on `email` — see the note there. Upgraded databases get this in the migration -- block at the foot of this file, AFTER the de-duplication that makes it -- addable; adding it here too is what gives a FRESH install the same shape. UNIQUE KEY uq_users_email_norm (email_norm) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS posts ( id INT AUTO_INCREMENT PRIMARY KEY, category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL, title VARCHAR(200) NOT NULL, slug VARCHAR(220) NULL, excerpt VARCHAR(400) NULL, body MEDIUMTEXT NULL, image_url VARCHAR(500) NULL, published TINYINT(1) NOT NULL DEFAULT 0, author_id INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_posts_feed (category, published, published_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki categories / sections. Defined before wiki_pages so the FK resolves on a -- fresh install. Pages reference a category (nullable = "Uncategorized"). CREATE TABLE IF NOT EXISTS wiki_categories ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, description VARCHAR(400) NULL, sort_order INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_pages ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, published TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_wiki_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_wiki_category FOREIGN KEY (category_id) REFERENCES wiki_categories(id) ON DELETE SET NULL, FULLTEXT INDEX idx_wiki_search (title, body) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Wiki tags (many-to-many with pages). CREATE TABLE IF NOT EXISTS wiki_tags ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(120) NOT NULL UNIQUE, label VARCHAR(120) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS wiki_page_tags ( page_id INT NOT NULL, tag_id INT NOT NULL, PRIMARY KEY (page_id, tag_id), CONSTRAINT fk_wpt_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wpt_tag FOREIGN KEY (tag_id) REFERENCES wiki_tags(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Internal-link index, rebuilt on each save. target_slug may point at a page -- that does not exist yet (a "red link"). CREATE TABLE IF NOT EXISTS wiki_links ( source_page_id INT NOT NULL, target_slug VARCHAR(120) NOT NULL, CONSTRAINT fk_wiki_links_src FOREIGN KEY (source_page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, INDEX idx_wiki_links_target (target_slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Per-save content snapshots for history / diff / restore. CREATE TABLE IF NOT EXISTS wiki_revisions ( id INT AUTO_INCREMENT PRIMARY KEY, page_id INT NOT NULL, title VARCHAR(200) NOT NULL, body MEDIUMTEXT NULL, excerpt VARCHAR(400) NULL, category_id INT NULL, editor_id INT NULL, change_note VARCHAR(280) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_wiki_rev_page FOREIGN KEY (page_id) REFERENCES wiki_pages(id) ON DELETE CASCADE, CONSTRAINT fk_wiki_rev_editor FOREIGN KEY (editor_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_wiki_rev_page (page_id, id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS settings ( `key` VARCHAR(64) PRIMARY KEY, value TEXT NULL, updated_by INT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_settings_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NULL, action VARCHAR(64) NOT NULL, detail TEXT NULL, ip VARCHAR(45) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_activity_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Pluggable SSO / OAuth2 provider configuration. Rows exist for the built-in -- providers ('google', 'discord') once an admin configures them, plus any custom -- OIDC/OAuth2 providers (id = a slug). Client secrets are stored ENCRYPTED -- (client_secret_enc) and are never returned to a client. Built-in providers -- hardcode their endpoint URLs in code; the *_url columns are used only by -- custom (oidc/oauth2) providers. CREATE TABLE IF NOT EXISTS auth_providers ( id VARCHAR(64) PRIMARY KEY, -- 'google' | 'discord' | custom slug kind ENUM('google','discord','oidc','oauth2') NOT NULL, name VARCHAR(80) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, client_id VARCHAR(255) NULL, client_secret_enc TEXT NULL, -- AES-256-GCM ciphertext, never exposed authorize_url VARCHAR(500) NULL, -- custom providers only token_url VARCHAR(500) NULL, userinfo_url VARCHAR(500) NULL, scopes VARCHAR(500) NULL, priority INT NOT NULL DEFAULT 100, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Account linking: maps an external SSO identity to an internal user. A login via -- SSO succeeds only if a matching (provider, subject) row exists (link-only — -- external identities are never auto-provisioned into accounts). UNIQUE(provider, -- subject) guarantees one external identity maps to exactly one internal user. CREATE TABLE IF NOT EXISTS user_identities ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, provider VARCHAR(64) NOT NULL, -- matches auth_providers.id subject VARCHAR(191) NOT NULL, -- external stable user id (sub / discord id) email VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_identity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_identity_provider_subject (provider, subject), INDEX idx_identity_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Long-lived, revocable refresh tokens for mobile (Android) bearer-token auth. -- The opaque refresh token is never stored in the clear — only its sha256 hash — -- so a DB read does not leak usable tokens. Rows are rotated on every refresh -- (old row revoked, new row inserted) and revoked on logout. Web cookie sessions -- do NOT use this table; it is purely for the mobile bearer flow. CREATE TABLE IF NOT EXISTS mobile_refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque refresh token device_hash VARCHAR(32) NULL, -- from sessionService.sessionMeta (best-effort) device_name VARCHAR(100) NULL, -- friendly label the app may send (M9) user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME NULL, -- last time this session token was issued/used (M9) expires_at DATETIME NOT NULL, revoked_at DATETIME NULL, CONSTRAINT fk_mrt_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_mrt_user (user_id), INDEX idx_mrt_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Mobile SSO authorization bridge (M9). Two short-lived, self-pruning tables that -- bridge a browser SSO redirect flow to the native app. They carry the app↔website -- PKCE + CSRF state (a SECOND PKCE layer, distinct from the website↔IdP PKCE the -- sso_tx cookie already carries) and the one-time code the app trades for bearer -- tokens. No secret is stored in the clear: code_challenge is a hash by construction -- and the authorization code is stored as a sha256 hash only (same pattern as -- mobile_refresh_tokens / user_invites / password_resets). See docs BACKEND_DESIGN §3/§4. CREATE TABLE IF NOT EXISTS mobile_auth_sessions ( id INT AUTO_INCREMENT PRIMARY KEY, session_id CHAR(36) NOT NULL UNIQUE, -- uuid; carried inside the signed sso_tx (mode 'mobile') provider VARCHAR(40) NOT NULL, -- provider id, validated enabled at /start code_challenge VARCHAR(255) NOT NULL, -- app-supplied PKCE S256 challenge (base64url) redirect_uri VARCHAR(255) NOT NULL, -- app callback; EXACT-match against the allowlist state VARCHAR(255) NOT NULL, -- app-generated opaque CSRF value, echoed to the app status ENUM('pending','completed','consumed') NOT NULL DEFAULT 'pending', user_id INT NULL, -- set once SSO resolves the account trust_device TINYINT(1) NOT NULL DEFAULT 0, -- user ticked "trust this device" on the Custom Tab TOTP form; -- a BOOLEAN only — the trust token itself is minted at /exchange -- and returned over that app→server call, never stored here created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, -- ~10 min (one redirect round-trip incl. TOTP) used_at DATETIME NULL, -- stamped at exchange CONSTRAINT fk_mas_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_mas_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS mobile_auth_codes ( id INT AUTO_INCREMENT PRIMARY KEY, code_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque >=128-bit code user_id INT NOT NULL, session_id CHAR(36) NOT NULL, -- owning mobile_auth_sessions.session_id (ties code→PKCE challenge) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL, -- very short (~5 min) used_at DATETIME NULL, -- set on first successful exchange (single use) CONSTRAINT fk_mac_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_mac_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Denylist of revoked web/cookie session tokens, keyed on the JWT `jti` minted -- per session in createSession. A single logout adds this session's jti here; -- requireAuth rejects any token whose jti is present. Rows self-expire: expires_at -- mirrors the token's own exp, after which the JWT fails verification anyway, so -- the row is dead weight and gets pruned. "Log out everywhere" / password change -- do NOT use this table — they bump users.tokens_valid_after instead (one row vs. -- one-per-session). This is the web/cookie analogue of mobile_refresh_tokens. CREATE TABLE IF NOT EXISTS revoked_sessions ( jti CHAR(36) PRIMARY KEY, -- the session's JWT jti (uuid v4) user_id INT NULL, expires_at DATETIME NOT NULL, -- mirrors the token exp (prune after) revoked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_revoked_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_revoked_sessions_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Trusted devices for MFA (opt-in "Trust this device"). A trusted device lets a -- browser/app SKIP the TOTP step at login — never the password. Pattern-identical -- to mobile_refresh_tokens: the opaque trust token lives client-side (the rg_trust -- cookie on web, EncryptedSharedPreferences on mobile) and only its sha256 hash is -- stored here (token_hash UNIQUE, so the login path can look a device up in O(1)). -- sha256 (not bcrypt) because the token is a 256-bit random value looked up BY its -- hash — a per-row salt would break the index lookup. Trust is consulted only at -- the login/password step, never at token refresh, and is revoked on untrust / -- password change/reset / TOTP disable. Capped at 10 rows per user (enforced in -- application code — no silent pruning). See docs/website/TRUSTED_DEVICES_MFA.md. CREATE TABLE IF NOT EXISTS trusted_devices ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque trust token platform ENUM('web','mobile') NOT NULL DEFAULT 'web', device_name VARCHAR(100) NULL, -- friendly label for the Trusted Devices list device_hash VARCHAR(32) NULL, -- best-effort UA+IP (sessionMeta) — display only user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME NULL, -- stamped when trust is honored at login expires_at DATETIME NOT NULL, -- created_at + 30d revoked_at DATETIME NULL, CONSTRAINT fk_td_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_td_user (user_id), INDEX idx_td_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Single-use recovery (backup) codes for MFA. Generated at TOTP enrollment (10 at a -- time, shown to the user ONCE) so a user who loses their authenticator can complete -- login without an admin reset. code_hash is a BCRYPT hash (not sha256): a recovery -- code is a human-typed, lower-entropy fallback credential — the closest analogue to -- a password — and there is no hash-lookup constraint (we fetch the user's <=10 rows -- and bcrypt.compare each, exactly like password verification). Cleared wholesale on -- TOTP disable / password change/reset. See docs/website/TRUSTED_DEVICES_MFA.md. CREATE TABLE IF NOT EXISTS recovery_codes ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, code_hash VARCHAR(72) NOT NULL, -- bcrypt hash of one recovery code used_at DATETIME NULL, -- single-use marker created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_rc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_rc_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Discord bot control (Phase 1). Singleton row (id = 1) holding the bot's -- config — the token is encrypted at rest (bot_token_enc) the same way OAuth -- client secrets are, and is only ever decrypted server-side to push to the -- bot process over the internal API; it is never returned to the admin UI -- and the bot process never reads this table directly. `status`/`status_detail` -- /`last_connected_at` are last-known-state mirrors of what the bot reported, -- shown in the admin panel between polls. CREATE TABLE IF NOT EXISTS bot_config ( id INT PRIMARY KEY DEFAULT 1, guild_id VARCHAR(32) NULL, bot_token_enc TEXT NULL, application_id VARCHAR(32) NULL, enabled TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status_detail VARCHAR(500) NULL, last_connected_at DATETIME NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_bot_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_bot_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Outbound email configuration. Singleton row (id = 1), mirroring bot_config: the -- DB only ever holds the AES-256-GCM-encrypted credential, never plaintext, and it -- is write-only over the admin API (never returned; responses expose only -- hasCredential and the non-secret fields the transport declares). -- -- `transport` names a registered mail transport (server/src/engagement/transports). -- `credential_enc` is that transport's whole credential set as one encrypted JSON -- blob rather than a column per field, because the field list is the transport's to -- declare — SMTP wants host/port/secure/user/password, an API relay wants a domain -- and a key, and a column per union member would make adding a transport a schema -- change. ENGAGEMENT.md §3.1. -- -- `provider` and `refresh_token_enc` are DEPRECATED and no longer read: they held -- the removed Gmail OAuth2 connection (ENGAGEMENT.md §1.2a). They are kept rather -- than dropped under the additive-only discipline, and `refresh_token_enc` earns -- its keep in the meantime as the marker for "this deployment had working mail -- before the upgrade" — which is what the admin dashboard warning reads. CREATE TABLE IF NOT EXISTS email_config ( id INT PRIMARY KEY DEFAULT 1, provider VARCHAR(20) NOT NULL DEFAULT 'gmail_oauth2', -- DEPRECATED, unread transport VARCHAR(32) NOT NULL DEFAULT 'smtp', enabled TINYINT(1) NOT NULL DEFAULT 0, sender_email VARCHAR(255) NULL, -- envelope From, operator-typed sender_name VARCHAR(120) NULL, -- optional From display name reply_to VARCHAR(255) NULL, -- optional Reply-To for sent mail credential_enc TEXT NULL, -- AES-256-GCM ciphertext (JSON), never exposed refresh_token_enc TEXT NULL, -- DEPRECATED, unread; see above status VARCHAR(20) NOT NULL DEFAULT 'unconfigured', status_detail VARCHAR(500) NULL, last_verified_at DATETIME NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_email_config_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT chk_email_config_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Site-side mirror of in-game-account → website-user links. The sidecar is the -- source of truth (it tags the game account with the websiteUserId on -- /link/confirm); this table mirrors it so the player portal can list a user's -- linked accounts and enforce ownership on roster/vendor reads without a shard -- round-trip. account is unique (one game account maps to at most one site user); -- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone -- by email at a pre-chosen access level; the invitee accepts via a tokened link, -- which creates their website user at that role (and optionally a linked game -- account). Only the sha256 hash of the opaque token is stored — a DB read never -- yields a usable invite link, same as mobile_refresh_tokens. status tracks the -- lifecycle; accepted_user_id back-points at the created user. Single-use + -- expiring (enforced in the model on top of expires_at). CREATE TABLE IF NOT EXISTS user_invites ( id INT AUTO_INCREMENT PRIMARY KEY, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token email VARCHAR(255) NOT NULL, role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player', status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending', invited_by INT NULL, -- staff user who sent it accepted_user_id INT NULL, -- the user created on accept expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, accepted_at DATETIME NULL, CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_user_invites_email (email), INDEX idx_user_invites_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Self-service password resets. A user requests a reset by email; a tokened link -- is emailed to every active account on that address. Opening the link and setting -- a new password rotates the hash and revokes all sessions (web + mobile). Only the -- sha256 hash of the opaque token is stored — a DB read never yields a usable link, -- same as user_invites / mobile_refresh_tokens. Single-use + short-lived (1h, -- enforced in the model on top of expires_at). Also serves SSO-only accounts (null -- password_hash) as their "set an initial password" path. CREATE TABLE IF NOT EXISTS password_resets ( id INT AUTO_INCREMENT PRIMARY KEY, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token user_id INT NOT NULL, -- the account this reset targets status ENUM('pending','used') NOT NULL DEFAULT 'pending', requested_ip VARCHAR(64) NULL, -- who asked (audit only) expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, used_at DATETIME NULL, CONSTRAINT fk_password_resets_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_password_resets_user (user_id), INDEX idx_password_resets_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Self-service email verification (engagement Phase 1b). The same shape as -- password_resets, deliberately: an opaque random token whose sha256 is all that -- is stored, single-use, short-lived. The design of record calls this link -- "signed"; every comparable flow in this codebase (user_invites, -- password_resets, mobile_refresh_tokens) uses a hashed random token instead, and -- matching them beats introducing a second token mechanism for one caller. -- -- The address lives on the ROW, not just on the user: a token proves control of -- the address it was mailed to, so if the user changes their mind and requests a -- different address, the older token must not be able to confirm the newer one. CREATE TABLE IF NOT EXISTS email_verifications ( id INT AUTO_INCREMENT PRIMARY KEY, token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token user_id INT NOT NULL, email VARCHAR(255) NOT NULL, -- the address THIS token proves status ENUM('pending','used') NOT NULL DEFAULT 'pending', requested_ip VARCHAR(64) NULL, -- who asked (audit only) expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, used_at DATETIME NULL, CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_email_verifications_user (user_id), INDEX idx_email_verifications_status (status, expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Who lost an address to the Phase 1b de-duplication, and what they lost. -- -- These accounts are exactly the ones an operator must contact: they can no -- longer receive password-reset or engagement mail until they set a new address. -- Written by the migration below in pure SQL (ensureSchema() reads this file -- statement-by-statement and there is no JS migration hook), surfaced as a -- dashboard warning until acknowledged. -- -- No foreign key to users, on purpose: the same reasoning as posts.announce_job_id -- — a constraint re-added on every boot is a constraint that can fail a boot, and -- this table is a historical record rather than a live relation. CREATE TABLE IF NOT EXISTS email_dedupe_report ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, username VARCHAR(32) NOT NULL, -- captured at clear time lost_address VARCHAR(255) NOT NULL, cleared_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, acknowledged_at DATETIME NULL, -- set when an admin dismisses the warning -- Makes the migration's INSERT strictly idempotent: an account cleared once is -- never reported twice, however many times ensureSchema() runs. UNIQUE KEY uq_edr_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── Push notifications (opt-in) ───────────────────────────────────────────── -- One row per registered push endpoint (Android/UnifiedPush v1; FCM later). The -- `endpoint` is the UnifiedPush distributor URL the app's ntfy topic was handed — -- unguessable but NOT a secret (the security model treats ntfy as an untrusted -- relay and only ever pushes content-free tickles), so it is stored in the clear, -- unlike mobile_refresh_tokens. A device belongs to one user; re-registering the -- same endpoint for the same user is an idempotent upsert (UNIQUE user_id+endpoint). CREATE TABLE IF NOT EXISTS push_devices ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, transport ENUM('unifiedpush','fcm') NOT NULL DEFAULT 'unifiedpush', endpoint VARCHAR(512) NOT NULL, -- distributor URL (or FCM token) platform VARCHAR(40) NULL, -- e.g. 'android' (free-form label) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_push_devices_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_push_devices_user_endpoint (user_id, endpoint), INDEX idx_push_devices_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Which notification streams a user has opted into. Subscriptions are per-user -- (applied to every device the user has registered), not per-device. stream_id is -- an id from the notification catalog (config/notificationStreams.js), validated -- in the model on write. One row per (user, stream); PUT replaces the whole set. CREATE TABLE IF NOT EXISTS notification_subscriptions ( user_id INT NOT NULL, stream_id VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, stream_id), CONSTRAINT fk_notif_subs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_notif_subs_stream (stream_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else -- (per the spec's "shared instance, clearly prefixed where needed" option) -- purely because there's no separate migration tooling to stand up a second -- database for a single-guild v1 bot. -- Per-guild key/value config the bot needs at runtime (currently just the -- mod-log channel; filters/schedules/role-menu config lands here in later -- phases). Set via the `/modlog set` slash command, not the admin panel — -- unlike bot_config (identity/connection secrets), this is routine Discord -- server administration staff already do inside Discord. CREATE TABLE IF NOT EXISTS guild_config ( guild_id VARCHAR(32) NOT NULL, `key` VARCHAR(64) NOT NULL, value VARCHAR(500) NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (guild_id, `key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Audit trail + mod-log source of truth for ban/kick/mute/warn actions. -- duration_seconds is only set for timed mutes; NULL for permanent -- ban/kick/warn actions. CREATE TABLE IF NOT EXISTS mod_actions ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, action_type ENUM('ban','kick','mute','warn') NOT NULL, target_user_id VARCHAR(32) NOT NULL, target_tag VARCHAR(120) NULL, staff_user_id VARCHAR(32) NOT NULL, staff_tag VARCHAR(120) NULL, reason VARCHAR(500) NULL, duration_seconds INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_mod_actions_target (guild_id, target_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Player-submitted moderation appeals (Phase 6c). Unlike mod_actions above, this -- table is SERVER-owned — written and read only by the main site (the player -- appeals controller and the admin moderation queue), never by the bot. A player -- appeals one of their own ban/mute mod_actions; staff triage the queue, and an -- approval optionally triggers an automatic Discord reversal (Phase 6d) whose -- outcome is recorded in reversal_status. mod_action_id is a plain column with NO -- hard FK to the bot-owned mod_actions table (cross-owner FK avoided on purpose, -- matching posts.announce_job_id) — existence is validated in app code. user_id -- is the appealing site account; discord_user_id is the snowflake the appeal is -- for (snapshotted from mod_actions.target_user_id at submit time). CREATE TABLE IF NOT EXISTS appeals ( id INT AUTO_INCREMENT PRIMARY KEY, mod_action_id INT NOT NULL, discord_user_id VARCHAR(32) NOT NULL, action_type ENUM('ban','mute') NOT NULL, user_id INT NULL, status ENUM('pending','under_review','approved','denied','withdrawn') NOT NULL DEFAULT 'pending', submitted_text TEXT NOT NULL, staff_response TEXT NULL, handled_by_user_id INT NULL, handled_by_tag VARCHAR(120) NULL, reversal_status ENUM('none','done','failed') NOT NULL DEFAULT 'none', submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, resolved_at DATETIME NULL, CONSTRAINT fk_appeal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_appeals_status (status, submitted_at), INDEX idx_appeals_action (mod_action_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Standing warnings, separate from mod_actions so /warnings can list active -- warnings per user. expires_at is unused in Phase 2 (no decay/escalation -- yet — deferred, see mute/warn command comments) but the column is cheap to -- add now rather than migrate in later. CREATE TABLE IF NOT EXISTS warnings ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, target_user_id VARCHAR(32) NOT NULL, target_tag VARCHAR(120) NULL, staff_user_id VARCHAR(32) NOT NULL, staff_tag VARCHAR(120) NULL, reason VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NULL, INDEX idx_warnings_target (guild_id, target_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Banned-word list (Phase 3). `word` is stored as the admin typed it; matching -- normalizes both sides at runtime (case, leetspeak, repeated chars — see -- bot/src/filter/normalize.js), so the stored value doesn't need every -- obfuscated variant. severity drives the auto-action: delete-only, delete + -- warn, or delete + mute (see messageFilter.js). The role/channel allowlist -- that bypasses filtering entirely lives in guild_config (keys -- filter_allow_roles / filter_allow_channels, CSV of snowflake ids) rather -- than a separate table — it's a short, rarely-changed list. CREATE TABLE IF NOT EXISTS filter_words ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, word VARCHAR(200) NOT NULL, severity ENUM('delete','warn','mute') NOT NULL DEFAULT 'delete', added_by VARCHAR(32) NULL, added_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_filter_words_guild_word (guild_id, word) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Scheduled/recurring messages (Phase 4). A row is EITHER recurring -- (cron_expression set, run_at NULL — reposts on the node-cron schedule -- forever until disabled/removed) OR one-off (run_at set, cron_expression -- NULL — posted once, then sent_at is stamped so the scheduler's due-message -- sweep never reposts it). content is plain text for now — the original spec -- allows richer embed JSON here, deferred since authoring embed JSON through a -- single slash-command string option isn't practical without a modal/admin UI. CREATE TABLE IF NOT EXISTS scheduled_messages ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, content VARCHAR(2000) NOT NULL, cron_expression VARCHAR(100) NULL, run_at DATETIME NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, sent_at DATETIME NULL, created_by VARCHAR(32) NULL, created_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_schedule_kind CHECK ( (cron_expression IS NOT NULL AND run_at IS NULL) OR (cron_expression IS NULL AND run_at IS NOT NULL) ), INDEX idx_scheduled_due (run_at, sent_at, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Self-assignable role menus (Phase 5). Button-based, not reaction-based — -- avoids needing the messageReactionAdd/Remove events and their own intent. -- `mapping` is a JSON array of {roleId, label}, validated against at click -- time (see bot/src/discord/roleMenuHandler.js) so a stale/foreign button -- customId can't toggle an untracked role. Auto-role-on-join is simpler and -- reuses guild_config (key auto_role_id) rather than a table of its own. CREATE TABLE IF NOT EXISTS role_menus ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, message_id VARCHAR(32) NOT NULL, mapping TEXT NOT NULL, created_by VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_role_menus_message (message_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Timed role assignments (temp-mute-equivalent roles, timed event roles). -- Swept once a minute (bot/src/roles/tempRoleSweeper.js) — expired rows have -- their Discord role removed and the row deleted. UNIQUE(guild,user,role) so -- re-granting the same temp role just refreshes its expiry via ON DUPLICATE -- KEY UPDATE rather than stacking duplicate rows. CREATE TABLE IF NOT EXISTS temp_roles ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, user_id VARCHAR(32) NOT NULL, role_id VARCHAR(32) NOT NULL, expires_at DATETIME NOT NULL, created_by VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uq_temp_roles_user_role (guild_id, user_id, role_id), INDEX idx_temp_roles_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Audit trail for the auto-rotating primary invite (Phase 6). triggered_by -- NULL means the weekly scheduled rotation did it, not a staff member — see -- bot/src/invites/inviteRotator.js, shared by both /invite rotate and the -- cron job so both paths log identically. The channel invites are created in -- is configured separately in guild_config (key invite_channel_id). CREATE TABLE IF NOT EXISTS invite_log ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, channel_id VARCHAR(32) NOT NULL, invite_code VARCHAR(20) NOT NULL, triggered_by VARCHAR(32) NULL, triggered_by_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at DATETIME NULL, INDEX idx_invite_log_guild (guild_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves -- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/ -- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_* -- record which invite was used when the bot could attribute it (best-effort, see -- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves. -- These are member lifecycle events, not moderation actions, hence separate from -- mod_actions. CREATE TABLE IF NOT EXISTS member_events ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, event_type ENUM('join','leave') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, invite_code VARCHAR(20) NULL, inviter_id VARCHAR(32) NULL, inviter_tag VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_member_events_guild (guild_id, created_at), INDEX idx_member_events_user (guild_id, discord_user_id, created_at), INDEX idx_member_events_invite (guild_id, invite_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Automated content-filter hits (Phase 6b): one row per message the word filter -- or the foreign-invite filter deleted. Separate from mod_actions (which still -- records the resulting warn/mute) so the dashboard can show filter volume in -- its own right. `matched` holds the offending word (word hits) or the blocked -- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned -- (bot/src/discord/messageFilter.js). CREATE TABLE IF NOT EXISTS filter_hits ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, hit_type ENUM('word','invite') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, channel_id VARCHAR(32) NULL, matched VARCHAR(200) NULL, action_taken ENUM('delete','warn','mute') NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_filter_hits_guild (guild_id, created_at), INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention / -- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting -- warn; this records the detection itself for the dashboard's spam feed. -- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js). CREATE TABLE IF NOT EXISTS spam_hits ( id INT AUTO_INCREMENT PRIMARY KEY, guild_id VARCHAR(32) NOT NULL, spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL, discord_user_id VARCHAR(32) NOT NULL, username VARCHAR(120) NULL, channel_id VARCHAR(32) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_spam_hits_guild (guild_id, created_at), INDEX idx_spam_hits_user (guild_id, discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Staff notes on a Discord user, surfaced in the admin moderation dashboard -- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written -- and read only by the main site (moderation.controller), never by the bot. -- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so -- notes attach to a Discord identity even when it has no linked site account. -- Notes are never user-visible; admin_only notes are further restricted to the -- admin role (moderators see staff_only only) — enforced in the query layer. CREATE TABLE IF NOT EXISTS mod_notes ( id INT AUTO_INCREMENT PRIMARY KEY, discord_user_id VARCHAR(32) NOT NULL, author_user_id INT NULL, author_tag VARCHAR(120) NULL, body TEXT NOT NULL, visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_mod_notes_user (discord_user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Generic CMS pages composed from a fixed palette of blocks (the page builder). -- `blocks` is a JSON array of block-envelope objects ({ id, type, version, -- visible, props }); it is stored as text and parsed/validated in app code -- against the block registry (server/src/blocks) on every save — the same -- pattern role_menus.mapping uses, since MariaDB's JSON type is just LONGTEXT and -- the driver hands it back as a string anyway. The seo_*/og_image/canonical_url/ -- robots and layout/nav_* columns are metadata/settings surfaced grouped in the -- API response; several have no consumer yet but are cheap to add now and painful -- to retrofit once real pages exist. published_at mirrors posts: stamped the first -- time a page goes to 'published'. CREATE TABLE IF NOT EXISTS pages ( id INT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(160) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, blocks MEDIUMTEXT NOT NULL, -- JSON array of block objects status ENUM('draft','published') NOT NULL DEFAULT 'draft', protected TINYINT(1) NOT NULL DEFAULT 0, author_id INT NULL, -- SEO / social metadata (grouped under `metadata` in the API response). seo_title VARCHAR(200) NULL, meta_description VARCHAR(400) NULL, og_image VARCHAR(500) NULL, canonical_url VARCHAR(500) NULL, robots VARCHAR(100) NULL, -- Presentation / navigation (grouped under `settings` in the API response). layout ENUM('default','full_width','landing') NOT NULL DEFAULT 'default', show_in_nav TINYINT(1) NOT NULL DEFAULT 0, nav_group ENUM('main','footer','account','hidden') NULL, nav_order INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, published_at DATETIME NULL, CONSTRAINT fk_pages_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_pages_status (status), INDEX idx_pages_nav (show_in_nav, nav_group, nav_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Announcement pipeline. One row per publish event of a news post; the table -- doubles as the job queue (a light in-process poller — utils/announceWorker.js -- — sweeps it for due legs). `status` is a derived rollup of the legs (see -- announceJobs.logic.js): done when every leg is done, failed when every leg is -- exhausted, partial in between. post_id is INT (matches posts.id) and cascades -- so deleting a post reaps its jobs. posts.announce_job_id points back at the -- latest row for admin lookups. CREATE TABLE IF NOT EXISTS announce_jobs ( id INT AUTO_INCREMENT PRIMARY KEY, post_id INT NOT NULL, status ENUM('pending','partial','done','failed') NOT NULL DEFAULT 'pending', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_announce_jobs_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- One row per delivery leg per job. INDEPENDENT by design: a Discord outage never -- blocks or retries another leg, and each leg tracks its own attempt count, last -- error and next-due time for exponential backoff. -- -- This is a child table rather than a pair of leg-prefixed column groups on -- announce_jobs because the leg set is DATA now, not schema: core registers -- `discord`, module-uo registers `towncrier`, and a module for another game -- registers its own — through modules/registries.js's registerAnnounceLeg -- (MODULE_SYSTEM.md §1.8). A module cannot ALTER a core table, so a leg that -- needed its own columns could never come from a module at all. `leg` is a plain -- VARCHAR and not an ENUM for the same reason. CREATE TABLE IF NOT EXISTS announce_job_legs ( job_id INT NOT NULL, leg VARCHAR(64) NOT NULL, status ENUM('pending','done','failed') NOT NULL DEFAULT 'pending', attempts SMALLINT NOT NULL DEFAULT 0, last_error TEXT NULL, next_attempt_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (job_id, leg), CONSTRAINT fk_announce_job_legs_job FOREIGN KEY (job_id) REFERENCES announce_jobs(id) ON DELETE CASCADE, INDEX idx_announce_leg_due (status, next_attempt_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Carry the two hardcoded leg column groups over to the child table, once. Guarded -- on the OLD columns still existing (via information_schema, since a plain SELECT -- of a dropped column is a parse error, not a runtime one) and on there being no -- row already, so replaying this file on every boot is a no-op after the first. -- Deleting this block once every deployment has booted it is safe. SET @has_legacy_legs := ( SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'announce_jobs' AND COLUMN_NAME = 'towncrier_status' ); SET @sql := IF(@has_legacy_legs > 0, 'INSERT IGNORE INTO announce_job_legs (job_id, leg, status, attempts, last_error, next_attempt_at) SELECT id, ''towncrier'', towncrier_status, towncrier_attempts, towncrier_last_error, towncrier_next_attempt_at FROM announce_jobs UNION ALL SELECT id, ''discord'', discord_status, discord_attempts, discord_last_error, discord_next_attempt_at FROM announce_jobs', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- MariaDB's IF EXISTS makes this idempotent, so it replays cleanly like the rest -- of the file. It is the one DROP in core's schema, and it is deliberate: leaving -- the columns would leave `towncrier` in a core file, which Phase 3's acceptance -- grep forbids (MODULE_SYSTEM.md §2.7). ALTER TABLE announce_jobs DROP COLUMN IF EXISTS towncrier_status, DROP COLUMN IF EXISTS towncrier_attempts, DROP COLUMN IF EXISTS towncrier_last_error, DROP COLUMN IF EXISTS towncrier_next_attempt_at, DROP COLUMN IF EXISTS discord_status, DROP COLUMN IF EXISTS discord_attempts, DROP COLUMN IF EXISTS discord_last_error, DROP COLUMN IF EXISTS discord_next_attempt_at, DROP INDEX IF EXISTS idx_announce_due, DROP INDEX IF EXISTS idx_announce_due_discord; -- Installed modules (module system, docs/website/MODULE_SYSTEM.md §2.4). One row -- per module the operator has installed onto the modules volume, keyed by the -- module id from its module.json — the same id that names the directory, the URL -- segment and the client registry key. -- -- This table is a RECORD of what happened, never the source of truth for what is -- mounted: the loader scans the filesystem at require time, before the database is -- reachable (MODULE_API.md §4.1), so the URL surface is a property of the volume -- and not of a row here. What the row decides is whether a mounted module answers -- (`disabled` ⇒ its guard 404s, §4.5) and what the admin panel shows after a -- failure. -- -- `state` is the §2.4 machine in one column: installed → enabled → started, with -- disabled and startup_failed as the recoverable states. `installed` is the -- transient state between an install writing the row and the restart that starts -- it. On every boot each non-disabled row is reset to `enabled` and re-attempted -- (so a fixed module recovers on restart, with no panel visit needed), then the -- load outcome writes `started` or `startup_failed`. Only `disabled` survives a -- boot untouched — it is the operator's decision, not an outcome. -- -- failure_stage/failure_reason are §4.4's recorded reason, one of the seven -- validation steps of §4.3 plus `boot`. Both are cleared by every transition that -- is not a failure, so a stale reason can never be shown against a running module. -- -- source/sha256 are install provenance (§2.5): the release the bundle came from and -- the digest that was verified before unpacking. Both NULL for a directory placed -- on the volume by hand, which stays supported. CREATE TABLE IF NOT EXISTS installed_modules ( id VARCHAR(32) NOT NULL PRIMARY KEY, -- module.json id; names the directory name VARCHAR(128) NOT NULL, -- human label for the admin Modules screen version VARCHAR(32) NOT NULL, -- module.json version (semver) state ENUM('installed','enabled','disabled','started','startup_failed') NOT NULL DEFAULT 'installed', failure_stage VARCHAR(32) NULL, -- manifest|core_api|mounts|extensions|schema|require|register|boot failure_reason TEXT NULL, -- the recorded reason, shown in the admin panel source VARCHAR(255) NULL, -- release URL the bundle came from sha256 CHAR(64) NULL, -- verified bundle digest installed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at DATETIME NULL, -- last successful start updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 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, -- 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 -- 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; -- ── Team forums (TEAMS.md Part 5, phase 4 "5a") ──────────────────────────── -- -- The WHOLE forum schema lands here, in 5a, including the columns only 5b uses. -- That is §5.1's split-by-layer: 5a ships the access model and announcements, 5b -- enables discussion by opening paths rather than by migrating data. `type`, -- `locked`, `pinned` and the whole post table exist from day one so that the -- second half adds no ALTER. -- -- Every table here is guarded by `teams_forums_enabled` at the ROUTE level and -- never at the data level (§5.5.1). Switching the forum off must not delete a -- thread, revoke a grant or clear a subscription, because the operator will -- switch it back on and expects what they had. CREATE TABLE IF NOT EXISTS team_forum_threads ( id INT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, type ENUM('announcement','discussion') NOT NULL DEFAULT 'discussion', title VARCHAR(200) NOT NULL, created_by INT NULL, -- SET NULL: the body survives the account (§2.10) created_username VARCHAR(32) NULL, -- snapshot, so a deleted author still reads created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, last_post_at DATETIME NULL, post_count INT NOT NULL DEFAULT 0, pinned TINYINT(1) NOT NULL DEFAULT 0, locked TINYINT(1) NOT NULL DEFAULT 0, status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible', CONSTRAINT fk_tft_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, CONSTRAINT fk_tft_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_tft_team_feed (team_id, status, pinned, last_post_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- `body_html` is sanitised ON WRITE and served without re-sanitising, the same -- contract the wiki and the CMS already follow — but through the FORUM's own -- profile (utils/forumHtml.js), not the shared one. The shared profile allows -- `` from any host, which would make `teams_forum_images` unenforceable: -- every post could hotlink in every mode and the setting would be decoration. -- No stored body ever contains an ``; core's renderer emits those at read -- time from the URLs the author wrote (§5.5.3), which is why flipping the policy -- back to `disabled` un-renders every image on every existing post with no -- migration at all. CREATE TABLE IF NOT EXISTS team_forum_posts ( id BIGINT AUTO_INCREMENT PRIMARY KEY, thread_id INT NOT NULL, author_user_id INT NULL, author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, edited_at DATETIME NULL, edited_by INT NULL, status ENUM('visible','hidden','deleted') NOT NULL DEFAULT 'visible', CONSTRAINT fk_tfp_thread FOREIGN KEY (thread_id) REFERENCES team_forum_threads(id) ON DELETE CASCADE, CONSTRAINT fk_tfp_user FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_tfp_editor FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_tfp_thread (thread_id, status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Append-only. Never updated, never deleted. -- -- Deliberately NOT merged into the site's mod_actions/appeals pair (§5.3), which -- is Discord-sanction-shaped and bot-owned: routing a guild leader locking a -- thread through it would make ordinary housekeeping an appealable sanction with -- a reversal path into the bot. The two are cross-referenced instead — every -- STAFF-exercised action here additionally writes an activity_log row, so the -- site's staff-accountability trail sees it; a LEADER-exercised one writes only -- this ledger. `actor_role` records WHICH authority was exercised, which is the -- column that makes that distinction auditable after the fact. CREATE TABLE IF NOT EXISTS team_forum_moderation ( id BIGINT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, target_type ENUM('thread','post') NOT NULL, target_id BIGINT NOT NULL, action ENUM('pin','unpin','lock','unlock','hide','unhide','delete','restore') NOT NULL, actor_user_id INT NULL, actor_username VARCHAR(32) NULL, -- snapshot (§2.10) actor_role ENUM('leader','staff') NOT NULL, reason VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_tfm_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, CONSTRAINT fk_tfm_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_tfm_target (target_type, target_id), INDEX idx_tfm_team (team_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Upload attribution (§5.2a, §5.5.4). Not bookkeeping: the acknowledgement an -- operator gives before enabling uploads is meaningless if "who uploaded this" -- cannot be answered afterwards, and the deletion sweep needs a row to sweep. -- -- `post_id` is NULL between the upload and the post that embeds it — the composer -- uploads first and references the URL in the body — and that is exactly the state -- the orphan sweep looks for. `deleted_at` is a soft delete: the file survives a -- retention window so a mis-click is recoverable, then the nightly sweep removes -- the bytes. CREATE TABLE IF NOT EXISTS team_forum_uploads ( id BIGINT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, post_id BIGINT NULL, uploader_user_id INT NULL, uploader_username VARCHAR(32) NULL, -- snapshot: attribution must survive the account filename VARCHAR(255) NOT NULL, -- the STORED name, never originalname mimetype VARCHAR(64) NOT NULL, -- the SNIFFED type, never the client's header byte_size INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at DATETIME NULL, deleted_by INT NULL, CONSTRAINT fk_tfu_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, CONSTRAINT fk_tfu_post FOREIGN KEY (post_id) REFERENCES team_forum_posts(id) ON DELETE SET NULL, CONSTRAINT fk_tfu_user FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_tfu_deleter FOREIGN KEY (deleted_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_tfu_filename (filename), INDEX idx_tfu_uploader (uploader_user_id, created_at), INDEX idx_tfu_sweep (deleted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Member-raised abuse reports (§5.6). **Core had no user-facing report flow of -- any kind before this**: `moderation`, `mod_notes` and `appeals` are all either -- staff-initiated or Discord-sanction-shaped, and nothing anywhere let a MEMBER -- say "this is a problem". That was survivable while every piece of content on -- the site came from staff. It stops being survivable the moment a Team forum -- lets players write to each other, and stops twice over when `uploads` mode lets -- them put files on the operator's disk under a signed liability acknowledgement. -- -- The gap has a specific shape worth naming: leaders moderate their own Team's -- forum, and a Team's leaders are exactly the people who will not report their own -- Team. So this table's whole point is a path that routes AROUND a Team's own -- leadership — **reports go to site staff and to nobody else.** There is -- deliberately no leader-facing view of this queue (org lead, 2026-08-18); a -- leader-visible report about a leader is not a report. -- -- Not a `team_*` table, and not named for the forum: `target_type` is a plain -- VARCHAR so wiki pages, news comments and profile fields become new values -- rather than new tables. Team forum content is only the first consumer. -- -- **The unique key is on an `open_marker`, not on `status`.** §5.6 writes the key -- as (target_type, target_id, reporter_user_id, status), and that spelling has a -- defect worth recording rather than quietly fixing: it makes CLOSED rows collide -- with each other too. A reporter reports a post, staff dismiss it, the behaviour -- recurs, they report it again — and the second dismissal is an UPDATE into a -- (…, 'dismissed') tuple that already exists, so working the queue would start -- throwing duplicate-key errors after the first repeat reporter. -- -- The generated marker is the same trick `team_forum_grants.active_marker` uses: -- it is 1 while the report is OPEN and NULL once it is closed, and MySQL treats -- NULLs as distinct, so any number of closed reports coexist while at most one -- open one can. That is what §5.6's prose actually asks for — "one open report per -- (target, reporter)". -- -- NULL reporters (deleted accounts) are distinct for the same reason, which is -- also wanted: nothing should collapse two dead accounts' reports into one. -- -- `handled_note` is not in the design doc and earns its place: a queue whose -- resolution reason lives only in an activity_log line is one where the next -- staffer to see a repeat report cannot find out why the last one was dismissed. CREATE TABLE IF NOT EXISTS content_reports ( id INT AUTO_INCREMENT PRIMARY KEY, target_type VARCHAR(32) NOT NULL, -- 'team_forum_post' | 'team_forum_thread' | 'team_forum_upload' target_id BIGINT NOT NULL, team_id INT NULL, -- denormalised for the queue's filters reporter_user_id INT NULL, reporter_username VARCHAR(32) NULL, -- snapshot (§2.10): who raised it survives the account reason ENUM('spam','abuse','sexual','illegal','impersonation','other') NOT NULL, detail VARCHAR(500) NULL, status ENUM('open','reviewing','actioned','dismissed') NOT NULL DEFAULT 'open', handled_by INT NULL, handled_username VARCHAR(32) NULL, -- snapshot, same reason handled_note VARCHAR(500) NULL, handled_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, open_marker TINYINT(1) AS (IF(status IN ('open','reviewing'), 1, NULL)) STORED, CONSTRAINT fk_cr_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, CONSTRAINT fk_cr_reporter FOREIGN KEY (reporter_user_id) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_cr_handler FOREIGN KEY (handled_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_cr_one_open (target_type, target_id, reporter_user_id, open_marker), INDEX idx_cr_queue (status, created_at), INDEX idx_cr_team (team_id, created_at) ) 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; -- The per-Team activity feed (TEAMS.md §4.2, phase 3). Two writers, one table: -- core writes its own membership and rename items with source='core', and a module -- pushes game items through ctx.teams.activity.push with source=. That -- core writes here too is deliberate — the rendering path is exercised by core's -- own content from day one, so the feed is never empty on a deployment whose -- module pushes nothing. -- -- `summary` is ALREADY-RENDERED text and core never composes one (§4.1). Core -- cannot phrase "gained 15,000 gold" for a game whose vocabulary it does not know, -- and a core that templated it would have re-acquired exactly the game semantics -- the module system exists to remove. `kind` and `payload` are likewise opaque: -- core stores and filters them, and only the module's `team.overview` slot renders -- anything richer than the text. CREATE TABLE IF NOT EXISTS team_activity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, source VARCHAR(32) NOT NULL, -- 'core' or a module id kind VARCHAR(64) NOT NULL, -- namespaced ., opaque to core summary VARCHAR(255) NOT NULL, -- module-rendered; core never composes one -- Defaults to 'members' — fail closed. The module CHOOSES visibility per item; -- core ENFORCES it on the read path. Same shape as a module owning the -- public-safety filter for its push streams (MODULE_API.md §2.4). visibility ENUM('public','members') NOT NULL DEFAULT 'members', actor_member_key VARCHAR(191) NULL, actor_user_id INT NULL, payload JSON NULL, -- opaque; rendered only by the module's slot occurred_at DATETIME NOT NULL, -- when it happened in the game, not when it arrived -- Optional idempotence key. INSERT IGNORE against this unique index is the same -- trick shard_events already uses, and it is what makes a sidecar reconnect -- backfill safe: replaying a window of events re-posts nothing. dedupe_key CHAR(40) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_team_activity_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, -- Actor is SET NULL, not CASCADE (§2.10): deleting an account must not delete the -- Team's history of what happened, only the attribution. CONSTRAINT fk_team_activity_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_team_activity_dedupe (team_id, dedupe_key), INDEX idx_team_activity_feed (team_id, occurred_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Per-Team notification preference (TEAMS.md §6.3/§6.4, phase 6). OPT-OUT, not -- opt-in: a user in a single Team must never have to configure anything, so the -- absence of a row is the default and every column here is a deviation from it. -- -- Team scoping lives HERE and in the recipient computation, never in a stream id. -- The push catalog is a static registration validated at boot against a namespaced -- pattern; it cannot express one stream per Team, and stream ids are stored in -- notification_subscriptions rows that would then need garbage-collecting every -- time a Team archived. Four fixed streams plus this table is the same feature -- with nothing to collect. -- -- `last_digest_at` is the digest's ONLY state. There is no queue of pending items: -- the worker asks what arrived after this timestamp and re-runs the access -- resolver, so a deployment that was down for a day sends one correct digest -- rather than replaying a backlog, and a user who lost forum access between the -- post and the send is not emailed content they can no longer read. CREATE TABLE IF NOT EXISTS team_notification_prefs ( user_id INT NOT NULL, team_id INT NOT NULL, muted TINYINT(1) NOT NULL DEFAULT 0, -- 'off', and NOT the design-of-record's 'digest'. Digest-by-default would mean -- every member of every Team starts receiving daily mail the moment an operator -- connects Gmail, which is a decision about other people's inboxes made on their -- behalf. Email is therefore the one sink here that is opt-IN; the mute is still -- opt-out, because a mute silences something the user already asked for. -- -- It also keeps this column honest as a deviation-from-default: a row written to -- set `muted` alone leaves email exactly where it was. email_mode ENUM('off','digest','immediate') NOT NULL DEFAULT 'off', last_digest_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, team_id), CONSTRAINT fk_tnp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, CONSTRAINT fk_tnp_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, -- The digest worker's driving query is "rows in digest mode, oldest send first", -- which is a scan of this index rather than of every preference ever written. INDEX idx_tnp_digest (email_mode, last_digest_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── The integration bridge's configuration (TEAMS.md §7.2, phase 8) ───────── -- -- The SAME events as §6, delivered to a second consumer. Not a second pipeline: -- `utils/teamNotify.js` computes the recipient set once and hands the event to -- push, to email and now to this bridge. -- -- `team_id NULL` is the deployment-wide default and a per-Team row overrides it, -- which is what §7.2 asks for — but its `PRIMARY KEY (platform, team_id)` cannot -- express it: MariaDB coerces every PRIMARY KEY column to NOT NULL, so the -- default row is unrepresentable and the whole override mechanism has no base -- case. Hence the surrogate key plus a generated `team_key`, the same trick -- `teams.active_key` and `content_reports.open_marker` use: IFNULL folds the -- default row onto 0, which no `teams.id` can be, so one default and one row per -- Team coexist under a single UNIQUE key. It also buys the foreign key the -- original DDL had no room for — without it, deleting a Team leaves its bridge -- config behind to be inherited by the next Team that lands on the id. -- -- **`members_ack` is a precondition, not a preference.** Forum posts and -- announcements are members-only ALWAYS — there is no public forum thread, and -- §7.2's gate ("visibility is public, or the channel is configured for a -- members-only context") has no data source on either side: the streams carry no -- visibility and core cannot see a Discord channel's permissions. Only the -- operator can. So enabling a members-only event requires an explicit, attributed -- acknowledgement that the destination is restricted to that Team, recorded the -- way `teams_forum_uploads_ack` records the image-policy one. Changing the channel -- CLEARS it (see the model): an acknowledgement is about a destination, and it -- cannot survive the destination changing underneath it. CREATE TABLE IF NOT EXISTS team_integration_config ( id INT AUTO_INCREMENT PRIMARY KEY, platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, phase 10 makes it a registry key team_id INT NULL, -- NULL = the deployment-wide default events JSON NOT NULL, -- ['team.announcement','team.forum.post'] channel_ref VARCHAR(64) NULL, -- destination on that platform, opaque to core enabled TINYINT(1) NOT NULL DEFAULT 0, -- The §7.2 gate, as an operator assertion with a name against it. members_ack TINYINT(1) NOT NULL DEFAULT 0, members_ack_by INT NULL, members_ack_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, team_key INT AS (IFNULL(team_id, 0)) STORED, UNIQUE KEY uq_tic_platform_team (platform, team_key), CONSTRAINT fk_tic_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE, -- SET NULL rather than CASCADE, for the same reason every other snapshot in -- this file is: deleting the admin's account must not silently un-acknowledge a -- policy and start withholding messages the deployment is configured to send. CONSTRAINT fk_tic_ack_by FOREIGN KEY (members_ack_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── Per-Team external resources: the voice channel (TEAMS.md §7.3, phase 9) ─ -- -- One row per (Team, platform, resource). Today the only resource is 'voice', -- and the column exists because the NEXT one — a text channel, a Matrix room — -- is the same lifecycle with a different noun, and phase 10's capability -- registry needs somewhere to say which resources a platform declares. -- -- **Access is a per-Team ROLE, not per-member overwrites.** §7.3 designed -- overwrites-by-default with escalation to a role above ~90 members; the org lead -- settled on roles always (2026-08-18). That deletes `voice_overwrite_max` and the -- mode transition, and it moves the ceiling: the binding limit is no longer ~100 -- overwrites on one channel but Discord's guild-wide cap of 250 roles, which the -- admin panel surfaces rather than letting a create fail into `state='error'`. -- `role_ref` is therefore NOT the escalation artefact it was in §7.3 — it is the -- grant itself, and a row with a channel and no role is a broken row. -- -- **Two external refs, two lifetimes, and the pair is why this is a table rather -- than two columns on `teams`.** A channel can be deleted in Discord while the -- role survives, and vice versa; the reconciler has to be able to say "the role is -- there, the channel is not" and repair one without touching the other. -- -- `state` is core's belief about Discord, never Discord's own answer: the -- reconciler writes what it just did, and the next pass re-derives the truth. A -- Team dropping below the threshold goes to 'pending_removal' with `remove_after` -- set rather than being deleted at once (§7.3's grace window) — a Team hovering -- around the threshold would otherwise delete-and-recreate, changing the channel -- id and breaking every pinned link to it, and a voice channel holds no message -- history, so the window costs nothing to keep. CREATE TABLE IF NOT EXISTS team_integrations ( id INT AUTO_INCREMENT PRIMARY KEY, team_id INT NOT NULL, platform VARCHAR(32) NOT NULL, -- 'discord'; opaque here, a registry key in phase 10 resource VARCHAR(32) NOT NULL, -- 'voice' external_ref VARCHAR(64) NULL, -- the channel id role_ref VARCHAR(64) NULL, -- the Team's own role; the grant itself, not an escalation state ENUM('none','active','pending_removal','error') NOT NULL DEFAULT 'none', remove_after DATETIME NULL, -- set with 'pending_removal'; the grace window's expiry last_error VARCHAR(500) NULL, synced_at DATETIME NULL, -- last pass that reached Discord and was believed updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_team_integration (team_id, platform, resource), -- Expiry is swept across every Team, so the index is on the pair the sweep -- filters by rather than on the Team the unique key already covers. INDEX idx_ti_pending (state, remove_after), CONSTRAINT fk_ti_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE ) 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. -- (The category foreign key is only added on fresh installs; on upgraded databases -- referential integrity for category_id is enforced in application code.) -- Opt-in TOTP two-factor columns for databases created before login hardening. ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0; -- Session-revocation cutoff for databases created before token revocation landed. ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL; -- Moderation dashboard (Phase 6): add the 'moderator' role to databases created -- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is -- an idempotent no-op, so it is safe to run on every boot. -- Player accounts: widen the enum again to include 'player' (self-service public -- accounts). Same idempotent-MODIFY pattern. ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin'; -- Player accounts: make password_hash nullable (SSO-only players), pin the -- username collation (case-insensitive uniqueness backstop), and add the player -- columns to databases created before this. MODIFY is an idempotent no-op when -- the column already matches; ADD COLUMN IF NOT EXISTS is safe to re-run. ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(72) NULL; ALTER TABLE users MODIFY COLUMN username VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci; ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified TINYINT(1) NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active'; ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL; -- Player self-registration mode: disabled | password | sso | both. Default off, -- so the system behaves exactly as today until an admin opts in. INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); -- Team forum post edit window, in minutes (TEAMS.md §5.4, phase 5). Seeded rather -- than left absent so the value an operator sees on the settings screen is the -- value in force — an empty field that silently behaves as 15 is a field nobody -- trusts. INSERT IGNORE, so an operator who has already changed it keeps theirs. INSERT IGNORE INTO settings (`key`, value) VALUES ('teams_forum_edit_window_minutes', '15'); ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published TINYINT(1) NOT NULL DEFAULT 1; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS published_at DATETIME NULL; ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, body); -- News → town-crier + Discord announcement pipeline. Add the announcement-state -- columns to posts on databases created before the pipeline landed. announced_at -- is stamped once both legs deliver; announce_job_id points at the announce_jobs -- row for the post's admin status panel. Kept as a plain column (not a hard FK) -- so the idempotent boot migration never trips over a re-added constraint — the -- pointer is resolved in application code and the CASCADE on announce_jobs.post_id -- already keeps the two tables consistent. ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL; -- Mobile device sessions (M9): a friendly label the app may send at login, and -- the last time this session token was issued/used, for the "Active Devices" -- self-service list. Both nullable and additive; existing rows get them here. ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS device_name VARCHAR(100) NULL; ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME NULL; -- SSO trusted devices: records that the user ticked "trust this device" on the -- Custom Tab TOTP form, so /auth/mobile/sso/exchange knows to mint the app's own -- trust token. A boolean only — the token is returned over that app→server call -- and never persisted here (only its sha256 lands in trusted_devices). ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0; -- ── Engagement Phase 1: Gmail OAuth2 removed, SMTP is the baseline ────────── -- (ENGAGEMENT.md §1.2a). Additive on an upgraded database: `transport` backfills -- to 'smtp' for every existing row, and `credential_enc` starts NULL — so an -- upgraded deployment is deliberately CREDENTIAL-LESS until its operator supplies -- SMTP settings. That is the whole point of the G22 warning below: nothing about -- this fails loudly, so something has to say it out loud. ALTER TABLE email_config ADD COLUMN IF NOT EXISTS transport VARCHAR(32) NOT NULL DEFAULT 'smtp'; ALTER TABLE email_config ADD COLUMN IF NOT EXISTS credential_enc TEXT NULL; ALTER TABLE email_config ADD COLUMN IF NOT EXISTS reply_to VARCHAR(255) NULL; -- ── Engagement Phase 1b: one account per mailbox ─────────────────────────── -- (ENGAGEMENT.md Phase 1b / §0.6.) ORDER IS LOAD-BEARING and every statement here -- is idempotent — after the first successful boot each one matches zero rows. -- -- Why the generated column is added BEFORE the de-duplication rather than after: -- the de-dupe must group addresses exactly the way the index will, and it cannot -- do that with LOWER(email) = LOWER(email) in SQL, because that comparison uses -- the COLUMN's collation, which is accent-insensitive. Grouping on email_norm — -- the very column the UNIQUE index goes on — makes the two agree by construction -- instead of by a hand-matched COLLATE clause someone can get wrong later. -- (Tested: with the LOWER()=LOWER() form, jose@x.com was nulled as a "duplicate" -- of josé@x.com. They are different mailboxes.) -- 1. An empty string is a value, not an absence, so two accounts holding '' would -- collide under the index and stop the boot. Unreachable through the current -- routes (isEmail() rejects ''), but this runs against databases whose history -- we do not control. UPDATE users SET email = NULL WHERE email = ''; -- 2. The pending-address column and the uniqueness key. No index yet — a UNIQUE -- index here, before step 3, is precisely the ALTER that fails and takes the -- site down with it (§0.6 finding 1). ALTER TABLE users ADD COLUMN IF NOT EXISTS email_pending VARCHAR(255) NULL; ALTER TABLE users ADD COLUMN IF NOT EXISTS email_norm VARCHAR(255) COLLATE utf8mb4_bin AS (LOWER(email)) STORED; -- 3. Record every account about to lose its address, BEFORE nulling it — the -- report is the only place the lost value survives. Oldest-wins (§7.1 Q1): -- the earliest-created account keeps the address, ties broken by id so the -- outcome is deterministic. Verified status deliberately does NOT arbitrate — -- SSO set email_verified from the mere presence of an address, so it is too -- weak a signal to decide who keeps a mailbox (§0.6 finding 3). INSERT IGNORE INTO email_dedupe_report (user_id, username, lost_address) SELECT l.id, l.username, l.email FROM ( SELECT u.id, u.username, u.email FROM users u WHERE u.email_norm IS NOT NULL AND u.id <> (SELECT u2.id FROM users u2 WHERE u2.email_norm = u.email_norm ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1) ) AS l; -- 4. Clear the losers. NEVER deletes a row: multiple NULLs are legal under a -- UNIQUE index, so every account survives with its login intact and simply has -- no contact address until its owner sets one. The extra derived table is not -- decoration — MariaDB refuses a subquery on the table being updated (error -- 1093) without it. UPDATE users SET email = NULL, email_verified = 0 WHERE id IN (SELECT id FROM ( SELECT u.id FROM users u WHERE u.email_norm IS NOT NULL AND u.id <> (SELECT u2.id FROM users u2 WHERE u2.email_norm = u.email_norm ORDER BY u2.created_at ASC, u2.id ASC LIMIT 1) ) AS losers); -- 5. Now the table can hold it. ALTER TABLE users ADD UNIQUE INDEX IF NOT EXISTS uq_users_email_norm (email_norm); -- 6. The verification gate: may an UNVERIFIED address receive opt-in engagement -- mail? ON for a fresh install, OFF for an upgrade — the asymmetry is the G22 -- lesson, not an oversight. Turning it on retroactively would silently stop -- mailing every existing opted-in user on upgrade day, which is exactly the -- kind of quiet breakage Phase 1 had to write a dashboard warning to undo. -- "Fresh" is read off the users table: a database with no users has no one to -- surprise. Both statements are INSERT IGNORE, so an operator who has since -- changed the value keeps theirs. INSERT IGNORE INTO settings (`key`, value) SELECT 'email_verification_required', 'on' FROM DUAL WHERE (SELECT COUNT(*) FROM users) = 0; INSERT IGNORE INTO settings (`key`, value) VALUES ('email_verification_required', 'off'); -- The status a Gmail-connected deployment carries is 'connected', and after the -- upgrade that is a lie: nothing can send. Correct it once, narrowly. The WHERE -- makes this idempotent and self-limiting — it matches only a row that still holds -- a Gmail refresh token AND has no replacement credential, so re-running it after -- the operator configures SMTP touches nothing, and it can never overwrite a real -- status recorded by a later send. UPDATE email_config SET status = 'unconfigured', status_detail = 'Gmail OAuth2 was removed. Configure SMTP credentials in Admin - Settings - Email.' WHERE refresh_token_enc IS NOT NULL AND credential_enc IS NULL AND status <> 'unconfigured'; -- ── Per-channel notification preferences (ENGAGEMENT.md §4.5, Phase 3) ────── -- -- G8: `notification_subscriptions` above has no channel dimension. It answers -- "which streams does this user want pushed", and the shipped Android client's -- wire shape (`{ streams: [...] }`) is frozen around exactly that question. This -- table answers the general one — which streams AND triggers, on which channel, -- in which mode — and the old table becomes its push projection: every write to -- one fans out to the other (`notificationChannelPrefs.model`). -- -- `stream_id` names a stream OR a trigger id, ONE namespace (§7.2, settled in -- Phase 2). That decision is what keeps this primary key single-keyed: under two -- namespaces it would have needed a `kind` discriminator, and `news.post` would -- have meant two different rows forever. -- -- **A row exists only where a user has expressed something.** Absence is not -- "off" — it is "the channel's `defaultMode`", which lives in -- `src/engagement/channels.js` and nowhere else (§3.1, G9: per-channel defaults -- differ). All three of core's channels default 'off' today, so absence and off -- coincide; that is a fact about the current declarations, not about this table, -- and code must not assume it. The column DEFAULT below is the value a write with -- no mode takes, not the value a missing row means. CREATE TABLE IF NOT EXISTS notification_channel_prefs ( user_id INT NOT NULL, stream_id VARCHAR(64) NOT NULL, channel VARCHAR(32) NOT NULL, mode ENUM('off','instant','digest') NOT NULL DEFAULT 'off', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, stream_id, channel), CONSTRAINT fk_ncp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_ncp_channel (channel, mode) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Carry the existing push subscriptions across, once. Same shape as the -- announce_jobs -> announce_job_legs backfill above: an INSERT IGNORE ... SELECT, -- so replaying this file on every boot is a no-op after the first, and a user who -- has since turned a stream OFF is not resurrected by the next boot (their row -- exists with mode 'off', and INSERT IGNORE leaves it alone). -- -- 'instant' rather than the column default, because a row in -- notification_subscriptions IS an opt-in: the user asked to be pushed, and push -- has no digest mode to be asked into instead. INSERT IGNORE INTO notification_channel_prefs (user_id, stream_id, channel, mode) SELECT user_id, stream_id, 'push', 'instant' FROM notification_subscriptions; -- ── The engagement engine (ENGAGEMENT.md §4.1, §4.2a, §4.5 — Phase 4a) ────── -- -- Five tables and no delivery. A rule says "when this trigger fires, for these -- people, on these channels, no more often than this"; the outbox is the queue -- the grace window needs; the cooldown table is what makes "once per house" mean -- once per house; and the send log is the first answer this deployment has ever -- had to "did user X get the mail?". -- -- Nothing here sends anything. Core seeds no rules and `enabled` defaults to 0, -- so on a real deployment these five tables stay empty until an operator turns a -- rule on from the screen Phase 4b builds. -- What an operator actually configures: trigger -> audience -> template -> timing. -- -- `trigger_id` deliberately has NO foreign key and no existence check: a trigger -- is DECLARED IN CODE (§4.3), so the set of them is whatever registered on this -- boot. A rule naming a trigger no module currently registers is DORMANT — it is -- listed, it never fires, and it starts working again when the module comes back -- (§7.3). Deleting it on uninstall would silently destroy an operator's -- configuration on the strength of a module being temporarily absent. CREATE TABLE IF NOT EXISTS engagement_rules ( id INT AUTO_INCREMENT PRIMARY KEY, trigger_id VARCHAR(96) NOT NULL, name VARCHAR(160) NOT NULL, -- OFF by default (§7.1 Q3). A rule arrives inert and an operator turns it on, -- so no import, seed or restore can start mailing on its own. enabled TINYINT(1) NOT NULL DEFAULT 0, audience VARCHAR(32) NOT NULL DEFAULT 'owner', audience_segment_id INT NULL, -- §7.1 Q3: the hard stop that makes operator-editable rules safe to choose over -- code-registered ones. Counted in engagement_sends, enforced before the outbox -- row is written, never overridable from the rule editor beyond this column. max_sends_per_hour INT NOT NULL DEFAULT 100, channels JSON NOT NULL, template_keys JSON NOT NULL, conditions JSON NULL, cooldown_seconds INT NOT NULL DEFAULT 0, delay_seconds INT NOT NULL DEFAULT 0, cancel_on JSON NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_engr_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_engr_trigger (trigger_id, enabled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- §5.1a: an operator-composed segment over module-declared audiences. Stored as a -- boolean tree of audience ids + params; `ceiling` is DERIVED at save time as the -- NARROWEST ceiling in the tree (ceilings.meetAll) and re-checked against the -- trigger's own ceiling, so composition can never widen. It is a column rather -- than a runtime computation so an audit can read what a rule was allowed to -- reach without re-resolving it — and so a module that has since changed its -- audience's ceiling cannot retroactively widen a saved segment. -- -- `engagement_rules.audience_segment_id` above points here with NO foreign key, -- on purpose and for the same reason `trigger_id` has none: a rule whose segment -- has been deleted must go DORMANT, not silently fall back to its plain -- `audience` column. ON DELETE SET NULL would be exactly that silent fallback, -- and the fallback reaches a DIFFERENT set of people (§5.1a rule 4). CREATE TABLE IF NOT EXISTS engagement_audience_segments ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(160) NOT NULL, expression JSON NOT NULL, ceiling VARCHAR(32) NOT NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_engseg_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- §4.1. NOT `settings`: cooldown state is high-cardinality (recipients x rules x -- subjects), written on every fire, and asked "is this one pair still cooling?". -- A JSON blob under one settings key would be a read-modify-write of the whole -- deployment's cooldown state per event, with a lost-update race between two -- concurrent triggers. -- -- `subject_key` is what makes "one IDOC mail per player per day" the right rule -- instead of the wrong one: a player with four houses decaying should hear about -- all four, once each. Cooling per (rule, user) alone silently drops three. CREATE TABLE IF NOT EXISTS engagement_cooldowns ( rule_id INT NOT NULL, user_id INT NOT NULL, -- The SUBJECT the cooldown is about, opaque to core: a house serial, a vendor -- id, ''. NOT NULL with a '' default, because this is a PRIMARY KEY column and -- MariaDB would coerce a NULL one anyway. '' is "this rule cools per user, not -- per subject". subject_key VARCHAR(190) NOT NULL DEFAULT '', -- The CHANNEL the cooldown is about, added in Phase 11b after the live walk. -- Without it a rule naming two channels delivers on exactly ONE of them: the -- claim runs inside the engine's per-channel loop, `inapp` is ranked first on -- purpose (so push can reference its inbox row), and every later channel is -- then reported as cooled. Phase 11b's decision 8 requires the letter and the -- inbox item to fire together, so the cooldown is per delivery, not per -- occasion. VARCHAR like `engagement_outbox.channel`, and for the same reason: -- the channel set is data a module can extend. channel VARCHAR(32) NOT NULL DEFAULT '', last_fired_at DATETIME NOT NULL, fire_count INT NOT NULL DEFAULT 1, PRIMARY KEY (rule_id, user_id, subject_key, channel), CONSTRAINT fk_engc_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE, CONSTRAINT fk_engc_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -- So a prune worker can drop rows older than the longest configured cooldown. -- Without it this table grows without bound, which is the failure mode -- teamActivityPrune was written for. INDEX idx_engc_sweep (last_fired_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Widen the key on a deployment that already has the table. Two statements, and -- the second is guarded because MariaDB has no conditional form of a PRIMARY KEY -- change: re-running `DROP PRIMARY KEY, ADD PRIMARY KEY` on a table that already -- carries the new one is an error, not a no-op, so replaying this file on every -- boot would fail the whole schema after the first run. The guard reads the key -- itself out of information_schema rather than the column's existence, because -- `ADD COLUMN IF NOT EXISTS` above can succeed while the key change does not. -- -- Existing rows keep `channel = ''`, which is one stale cooldown per (rule, user, -- subject) that expires on its own interval. That is the right trade against -- deleting them: a cooldown that outlives its rewrite costs at most one delayed -- notification, and dropping the table would let a bounce storm through. ALTER TABLE engagement_cooldowns ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT ''; SET @engc_key_has_channel := ( SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'engagement_cooldowns' AND INDEX_NAME = 'PRIMARY' AND COLUMN_NAME = 'channel' ); SET @sql := IF(@engc_key_has_channel = 0, 'ALTER TABLE engagement_cooldowns DROP PRIMARY KEY, ADD PRIMARY KEY (rule_id, user_id, subject_key, channel)', 'DO 0'); PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- §4.2a. Modelled on announce_jobs / announce_job_legs. One row per -- (rule, user, channel) occurrence of an event. CREATE TABLE IF NOT EXISTS engagement_outbox ( id BIGINT AUTO_INCREMENT PRIMARY KEY, rule_id INT NOT NULL, trigger_id VARCHAR(96) NOT NULL, -- denormalized; survives a rule edit user_id INT NOT NULL, channel VARCHAR(32) NOT NULL, -- VARCHAR, never ENUM: the channel set is data subject_key VARCHAR(190) NOT NULL DEFAULT '', payload JSON NOT NULL, -- the declared variables, snapshotted at emit dedupe_key VARCHAR(190) NULL, status ENUM('scheduled','sending','sent','failed','cancelled','suppressed') NOT NULL DEFAULT 'scheduled', due_at DATETIME NOT NULL, attempts SMALLINT NOT NULL DEFAULT 0, last_error TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, sent_at DATETIME NULL, CONSTRAINT fk_engo_rule FOREIGN KEY (rule_id) REFERENCES engagement_rules(id) ON DELETE CASCADE, CONSTRAINT fk_engo_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -- **Scoped to the row's identity, and §4.2a's global `UNIQUE (dedupe_key)` is -- a defect this phase found while building it.** A dedupe key names the EVENT -- ("house 0x4001 entered IDOC"), and one event legitimately becomes many rows: -- an audience of fifty users is fifty rows, a rule spanning email and in-app -- doubles that, and two rules on one trigger double it again. Under a global -- unique index the FIRST of those inserts wins and every other one is silently -- ignored — ninety-nine recipients dropped by the mechanism meant to stop a -- replayed event becoming a second mail. Scoping it to (rule, user, channel) -- keeps exactly that guarantee and nothing more. UNIQUE KEY uq_engo_dedupe (rule_id, user_id, channel, dedupe_key), INDEX idx_engo_due (status, due_at), -- What a RESOLVING event queries: a house repaired back to LikeNew cancels -- every scheduled row for that (rule, user, house). INDEX idx_engo_cancel (rule_id, user_id, subject_key, status), -- Phase 14. The sweep is `status IN (terminal) AND created_at < ?`, and -- `idx_engo_due` cannot serve it: its second column is `due_at`. INDEX idx_engo_sweep (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- G15: the per-message record. Today "did user X get the mail?" is unanswerable. -- -- It is deliberately NOT a second address book: the address is stored as a -- sha256, which is enough to correlate a bounce (Phase 9) and useless as a -- mailing list. `user_id` is SET NULL rather than CASCADE so the log survives an -- account deletion — an audit of what this deployment sent must not be erasable -- by deleting the recipient. CREATE TABLE IF NOT EXISTS engagement_sends ( id BIGINT AUTO_INCREMENT PRIMARY KEY, outbox_id BIGINT NULL, rule_id INT NULL, trigger_id VARCHAR(96) NOT NULL, user_id INT NULL, channel VARCHAR(32) NOT NULL, transport VARCHAR(32) NULL, address_hash CHAR(64) NULL, status ENUM('sent','failed','suppressed','bounced','complained') NOT NULL, detail VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_engs_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_engs_trigger (trigger_id, created_at), INDEX idx_engs_user (user_id, created_at), -- The per-rule hourly ceiling (§7.1 Q3) is counted here, so the count has to be -- an index range scan rather than a table scan: it runs once per rule per event. INDEX idx_engs_rule_window (rule_id, created_at), -- Phase 14's retention sweep deletes by age alone, so it needs `created_at` -- LEADING. Every index above has it in second position, which serves a -- per-rule or per-user window and is useless to a whole-table horizon. INDEX idx_engs_sweep (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- §4.4. The mail (and, from Phase 7, in-app) bodies an operator can edit, stored -- as a validated block array rather than as raw HTML: `blocks` goes through the -- same validate-then-sanitize gate the CMS pages do, against the `email.*` -- registry (src/emailBlocks/). Storing operator HTML would hand the renderer an -- injection surface and give up the prop schemas. -- -- Three columns carry the whole "ship a better default without stealing an -- operator's work" mechanism (§4.6.1 property 3). `seed_key` says which shipped -- template a row came from, `seed_version` which revision of it, and `customized` -- whether a person has since edited it. The seeder updates a row whose version is -- behind ONLY while `customized = 0`; a customized row is left exactly as it is -- and the newer default is surfaced in the admin list instead. Same posture -- `settingsJson` takes: never overwrite what someone chose. -- -- `trigger_id` has no foreign key for the reason `engagement_rules.trigger_id` -- has none -- a trigger is declared in code, so the set of them is whatever -- registered on this boot. NULL means a reusable template not tied to one -- trigger, which is what every transactional seed is: `mailer` renders them by -- key, no rule involved. CREATE TABLE IF NOT EXISTS engagement_templates ( id INT AUTO_INCREMENT PRIMARY KEY, `key` VARCHAR(96) NOT NULL UNIQUE, name VARCHAR(160) NOT NULL, trigger_id VARCHAR(96) NULL, trigger_version INT NULL, channel VARCHAR(32) NOT NULL, subject VARCHAR(300) NULL, blocks MEDIUMTEXT NOT NULL, text_body MEDIUMTEXT NULL, status ENUM('draft','published') NOT NULL DEFAULT 'draft', -- Editable, NOT deletable -- the pages.protected flag, for the same reason: -- the system breaks without a password-reset body. protected TINYINT(1) NOT NULL DEFAULT 0, seed_key VARCHAR(96) NULL, seed_version INT NULL, customized TINYINT(1) NOT NULL DEFAULT 0, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_engt_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, INDEX idx_engt_trigger (trigger_id, channel, status), INDEX idx_engt_seed (seed_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── The email channel on the engine (ENGAGEMENT.md §4.2b — Phase 6) ───────── -- The scope an event is ABOUT, opaque to core and distinct from `subject_key`. -- -- They are two different things and Phase 6 is where that stopped being -- theoretical. `subject_key` is what a COOLDOWN is keyed on and comes from the -- trigger's declared `subjectKey` — for the four Team triggers that is `teamName`, -- a display string. `scope_key` is what a PREFERENCE and an UNSUBSCRIBE are keyed -- on, and it has to be a stable identifier: `team:12` survives a rename, and a -- Team renamed between the mail and the click must not orphan the unsubscribe -- link in it. Same vocabulary as engagement_digest_state.scope_key below. ALTER TABLE engagement_outbox ADD COLUMN IF NOT EXISTS scope_key VARCHAR(190) NULL; -- Phase 14 (retention). The two sweep indexes, for deployments whose tables -- predate them. `IF NOT EXISTS` on an index is MariaDB-only and already used -- above (`idx_wiki_search`), so this needs no INFORMATION_SCHEMA guard like the -- cooldown primary-key change did -- that one needed one because MariaDB has no -- conditional form of a PRIMARY KEY change, not because indexes lack one. ALTER TABLE engagement_outbox ADD INDEX IF NOT EXISTS idx_engo_sweep (status, created_at); ALTER TABLE engagement_sends ADD INDEX IF NOT EXISTS idx_engs_sweep (created_at); -- §4.2b: digest state, and DELIBERATELY not a digest queue. -- -- The generic engine enqueues an outbox row per (rule, user, channel) at emit -- time, carrying a snapshot of the payload. That is right for an instant send and -- wrong for a digest, and `teamDigestWorker`'s header says why in three -- properties: a deployment down for two days sends ONE digest rather than two -- days of replay; a post a moderator hid after it was written is not in the -- query so it is not in the mail; and a user who lost forum access between the -- post and the send is no longer in the recipient set. The third is a security -- property, and all three are properties of RE-DERIVING the content at send time. -- A snapshot taken at emit time has none of them. -- -- So a digest-mode recipient gets NO outbox row (see engine.js), and what -- generalizes is this: the state the worker keeps, lifted out of -- team_notification_prefs.last_digest_at so that a second digest — on another -- channel, or over another scope — needs no second column on somebody's -- preferences table. CREATE TABLE IF NOT EXISTS engagement_digest_state ( user_id INT NOT NULL, channel VARCHAR(32) NOT NULL, -- '' is deployment-wide; 'team:12' is one Team. NOT NULL with a '' default -- because this is a PRIMARY KEY column and MariaDB coerces a nullable one -- anyway — the same workaround team_integration_config and teams.active_key -- both carry, and the trap Part 4's preamble flags. scope_key VARCHAR(190) NOT NULL DEFAULT '', last_digest_at DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, channel, scope_key), CONSTRAINT fk_engd_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -- The worker's driving question is "whose email digest is due?", which is a -- range scan of this index rather than of every digest ever sent. INDEX idx_engd_due (channel, last_digest_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Carry the Team digest windows across, once. Replay-safe by construction: an -- INSERT IGNORE against the primary key, so the second and every later boot -- writes nothing, and a window the new worker has since MOVED is not dragged -- backwards by the next restart. -- -- Rows with a NULL last_digest_at are copied too, and that is deliberate rather -- than incidental: `clampSince` treats a missing row and a NULL stamp the same -- way (reach back one interval, not to the floor), so the copy is faithful — and -- copying only the stamped rows would make the backfill's own idempotence depend -- on which rows happened to have fired. INSERT IGNORE INTO engagement_digest_state (user_id, channel, scope_key, last_digest_at) SELECT user_id, 'email', CONCAT('team:', team_id), last_digest_at FROM team_notification_prefs; -- ── The in-app channel (ENGAGEMENT.md §4.5 G17 — Phase 7) ────────────────── -- The inbox. Core, game-agnostic, and the first sink core owns that CARRIES its -- content: a push tickle deliberately holds none and an email leaves the -- building, so this is the one place a message both belongs to this deployment -- and can be read without a mailbox. -- -- `dedupe_key` is the acceptance criterion, expressed as an index rather than as -- a check the writer has to remember: a replayed event, a retried outbox row and -- a module calling `ctx.inbox.push` twice all reduce to the same INSERT IGNORE. -- It is scoped to the USER (not to the rule and channel the outbox scopes by), -- because one event may legitimately be two outbox rows for one person — a rule -- spanning channels — and two inbox rows for it is one item shown twice. -- Multiple NULLs are permitted by a UNIQUE index, which is what "this item does -- not dedupe" means. -- -- `url` is stored RELATIVE only, validated with the character class -- `pageUrlTemplate` and the engine's `url` variables already use: it ends up in -- an href on a page a signed-in user is looking at, and `//evil.test/x` passes -- every "is it rooted" check anyone writes by hand. CREATE TABLE IF NOT EXISTS user_notifications ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, trigger_id VARCHAR(96) NOT NULL, title VARCHAR(300) NOT NULL, body TEXT NULL, -- rendered by the inapp template, sanitized on write url VARCHAR(500) NULL, -- relative only, validated like pageUrlTemplate dedupe_key VARCHAR(190) NULL, read_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_un_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE KEY uq_un_dedupe (user_id, dedupe_key), -- Both of the two questions this table is asked: "what is in my inbox" (the -- list, newest first) and "how many are unread" (the badge, on every page -- load). A single index answers both because `read_at` is IS NULL in one and -- unconstrained in the other, and `created_at` orders what is left. INDEX idx_un_unread (user_id, read_at, created_at), -- What the prune sweep queries. Without it the sweep is a table scan of every -- notification this deployment has ever written. INDEX idx_un_prune (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── Deliverability: suppression and bounces (ENGAGEMENT.md §4.5 G16 — Phase 9) ── -- The addresses this deployment has stopped mailing, and why. -- -- **Keyed on the ADDRESS, not the user** (§4.5), and after Phase 1b that is a -- deliberate choice rather than a workaround for a missing unique index. Two -- accounts can no longer share an address, but a bounce arrives as an ADDRESS — -- it does not know which account was behind it, and it stays true after the -- account that held it changed its address or was deleted. Keying on the user -- would forget a dead mailbox the moment anybody moved. -- -- `address_masked` is Phase 9's one addition to §4.5's DDL, and it exists because -- the hash-only table cannot be operated. An operator looking at a screen of -- sha256 digests cannot tell whether the list is three typos or a whole domain -- refusing mail, and un-suppressing somebody who fixed their mailbox is the one -- action this table has to support. `d***@example.com` is enough to act on and to -- see a domain-wide pattern in, and — the reason it is safe — the local part is -- destroyed rather than shortened, so the column is not an address book and -- cannot be turned back into one. It is NULLable because a row written from a -- correlation that only ever held a hash has nothing to mask. -- -- **`reason` is not a synonym for "the send failed".** `mailer.PERMANENT_CODES` -- classifies a failure as not-worth-retrying, and that set contains EAUTH and 554 -- — an authentication failure and a relay-wide policy refusal, neither of which -- is a fact about the recipient. Writing a suppression on every terminal failure -- would mean one wrong SMTP password suppresses every address the worker touches -- before anyone notices. Only recipient-scoped evidence reaches this table; see -- `src/engagement/bounceClassify.js`. CREATE TABLE IF NOT EXISTS engagement_suppressions ( address_hash CHAR(64) NOT NULL PRIMARY KEY, -- sha256 of the lowercased address address_masked VARCHAR(190) NULL, -- d***@example.com; never the local part channel VARCHAR(32) NOT NULL DEFAULT 'email', reason ENUM('bounce','complaint','manual','unverified') NOT NULL, detail VARCHAR(500) NULL, created_by INT NULL, -- the admin, for a manual row; NULL for automatic created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_engsup_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, -- The screen's two orderings: newest first, and filtered by reason. INDEX idx_engsup_created (created_at), INDEX idx_engsup_reason (reason, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── The Event System (EVENTS.md §D — Phase 1) ────────────────────────────── -- Six of the eleven core tables land here: the ones that do not depend on the -- module contract. The rest arrive with the phases that give them a writer -- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5, -- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in -- P8 (below) and `event_run_participants` in P10. -- -- Core tables, so no module prefix, and no game vocabulary anywhere below: an -- action id, a scope, a resource kind and a budget dimension are all opaque -- strings core stores and never interprets (§C). -- The arc. Definitions optionally belong to one, and the series is what carries -- continuity across them — "Royal Spy Mission -> Risky Partner -> Message From -- the Void" is a thing the tooling this replaces cannot express at all. -- -- The table lands in Phase 1 because `event_definitions.series_id` points at it; -- the routes that create and order one are Phase 4's, where the calendar makes -- an arc visible. CREATE TABLE IF NOT EXISTS event_series ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(160) NOT NULL, slug VARCHAR(160) NOT NULL, description TEXT NULL, -- Where this series sits among the others on the calendar. Not a position -- WITHIN the series: a definition's place in its arc is `event_definitions`' -- own `series_order` below, because that is the column an editor drags. ordering INT NOT NULL DEFAULT 0, created_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evser_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_evser_slug (slug) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- The thing that is listed, searched, scheduled and audited. -- -- **Three states, not five** (§E). There is no `submitted` and no `approved`: an -- admin publishes their own work, so there is nobody to submit it to, and a -- review state nobody uses is a state every query has to remember anyway. -- -- `owner_module` is NULLable and is the module that SHIPPED this definition as -- content, not the module whose actions its steps call — a definition may call -- three modules' verbs and belong to none of them. NULL means an operator -- authored it here, which is the ordinary case. -- -- `concurrency_key` is stored as the TEMPLATE, not as the rendered value -- (`invasion:{region}`), because it is rendered from a run's own params at -- materialisation (§E). A flat definition-id key would wrongly stop one -- definition running in two regions at once. -- -- `current_version_id` carries NO foreign key, deliberately, and it is the one -- column in this group without one: `event_versions.definition_id` already -- points back here, and a second FK in the other direction makes the pair a -- chicken and an egg on insert. CREATE TABLE IF NOT EXISTS event_definitions ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL, summary VARCHAR(500) NULL, -- The storyline. Sanitized HTML, the same treatment a wiki page gets. body MEDIUMTEXT NULL, image_url VARCHAR(500) NULL, owner_module VARCHAR(64) NULL, state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft', current_version_id INT NULL, -- The WORKING COPY of the spec - phases and their steps - as the author last -- saved it. §D's column list does not name it because §D describes what a -- PUBLISHED event is made of, and a version row is where a spec ends up. But -- "editing a draft is free; no version exists yet" (EVENTS.md "Versioning") -- has to mean the draft lives somewhere, and it cannot be an `event_versions` -- row: that table is immutable and a run pins one, so a mutable unpublished -- row in it would be the exact thing versioning exists to prevent. Publishing -- copies this column into a version and leaves it here as the next draft. spec JSON NOT NULL, series_id INT NULL, series_order INT NOT NULL DEFAULT 0, concurrency_key VARCHAR(190) NULL, -- The grace window (§E). A schedule that passed this many seconds ago while the -- process was down is `missed`, never a late silent start. grace_seconds INT NOT NULL DEFAULT 900, -- IANA, and it belongs to the EVENT rather than to the viewer: every listing -- this replaces is written in the shard's local zone, and a recurrence computed -- in UTC puts a Friday-8pm event at 7pm for half the year. timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', created_by INT NULL, updated_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evdef_series FOREIGN KEY (series_id) REFERENCES event_series(id) ON DELETE SET NULL, CONSTRAINT fk_evdef_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, CONSTRAINT fk_evdef_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_evdef_slug (slug), -- The admin list's default ordering, and the public calendar's filter. INDEX idx_evdef_state (state, updated_at), INDEX idx_evdef_series (series_id, series_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- An immutable snapshot of a whole definition spec: phases, steps, schedule, -- conditions, announcements. A run pins one, and that pin is the entire reason -- this table exists — it is what makes a run reproducible, and an audit -- answerable, after the definition has been edited underneath it. -- -- Nothing updates a row here. Editing a `ready` definition creates the NEXT -- version on publish; a live run keeps the version it pinned and is unaffected -- (EVENTS.md "Versioning, and editing a live event"). CREATE TABLE IF NOT EXISTS event_versions ( id INT AUTO_INCREMENT PRIMARY KEY, definition_id INT NOT NULL, version INT NOT NULL, spec JSON NOT NULL, published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, published_by INT NULL, CONSTRAINT fk_evver_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, CONSTRAINT fk_evver_user FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL, -- Two publishes racing for version 4 is one 1062, not two rows called 4. UNIQUE KEY uq_evver_def_version (definition_id, version) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- One occurrence of one definition, in one scope. -- -- The UNIQUE key below — not the claim — is what makes "one run per occurrence -- per scope" true (§E). The claim decides WHO advances an occurrence; this index -- is what stops two of them existing. `scope` is inside the key so a worldwide -- event fans out to many servers without colliding with itself, and it is -- module-opaque: core stores the string and never parses it. -- -- `scheduled_for` is UTC. The definition's IANA zone is what the occurrence was -- COMPUTED in (Phase 4); what is stored is the instant. -- -- `health` is a separate column from `status` because a run can be genuinely -- running and degraded at once — announcements landing, world writes parked — -- and one column cannot say both. `cleanup_status` is separate for the mirror -- reason: a run reaches `completed` with `cleanup_status = 'incomplete'` rather -- than being held open, and stays on the admin screen until a human resolves it. -- -- `version_id`'s foreign key has no ON DELETE clause, so it RESTRICTs: a run -- whose pinned spec had been deleted could not be explained afterwards, which is -- the one thing this table is for. CREATE TABLE IF NOT EXISTS event_runs ( id BIGINT AUTO_INCREMENT PRIMARY KEY, definition_id INT NOT NULL, version_id INT NOT NULL, -- Module-opaque, and '' rather than NULL for the single-scope case: it is part -- of a UNIQUE key, and multiple NULLs do not collide in MariaDB, so a NULL -- scope would silently permit two runs of one occurrence. scope VARCHAR(190) NOT NULL DEFAULT '', status ENUM('scheduled','starting','running','paused','ending', 'completed','cancelled','failed','missed') NOT NULL DEFAULT 'scheduled', health ENUM('ok','degraded','stalled') NOT NULL DEFAULT 'ok', cleanup_status ENUM('not_required','pending','complete','incomplete') NOT NULL DEFAULT 'not_required', current_phase VARCHAR(64) NULL, scheduled_for DATETIME NOT NULL, timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', concurrency_key VARCHAR(190) NULL, -- rendered from this run's params params JSON NULL, -- A rehearsal dispatches for real but is excluded from the public calendar and -- from participation history. Declared here in Phase 1 so the column exists -- before anything can create a run without it. rehearsal TINYINT(1) NOT NULL DEFAULT 0, started_at DATETIME NULL, ended_at DATETIME NULL, claimed_by VARCHAR(64) NULL, claim_expires_at DATETIME NULL, started_by INT NULL, last_error VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evrun_def FOREIGN KEY (definition_id) REFERENCES event_definitions(id) ON DELETE CASCADE, CONSTRAINT fk_evrun_version FOREIGN KEY (version_id) REFERENCES event_versions(id), CONSTRAINT fk_evrun_user FOREIGN KEY (started_by) REFERENCES users(id) ON DELETE SET NULL, UNIQUE KEY uq_evrun_occurrence (definition_id, scope, scheduled_for), -- The runner's materialise/advance scan: due runs by status. INDEX idx_evrun_due (status, scheduled_for), -- The admin run list, newest first, and the per-definition history. INDEX idx_evrun_def (definition_id, scheduled_for), -- Phase 2's overlap check. NULL keys are skipped by the index, which is right: -- a definition with no key never contends. INDEX idx_evrun_concurrency (concurrency_key, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- The work queue: one action invocation, claimed with the outbox's -- compare-and-set. This is the shape `engagement_outbox` already proved, and -- retries, timeouts, duplicate execution and resumption are all properties of -- this row rather than of a scheduler's memory. -- -- `idempotency_key` is minted ONCE at materialisation and does not vary by -- attempt (§E) — a retry re-sends the same key so the game side can recognise -- the repeat. It is generated by core rather than by the module because core is -- what guarantees its stability. -- -- `action_version` records what the step was AUTHORED against. A module that -- bumps its action makes the step render a warning in the editor rather than -- dispatch a mistyped parameter. -- -- `refused` is in the status set and is deliberately not `failed`: a cap breach -- means nothing is wrong with the system, and an author asked for more than this -- deployment allows. CREATE TABLE IF NOT EXISTS event_run_steps ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, phase VARCHAR(64) NOT NULL, seq INT NOT NULL, action_id VARCHAR(96) NOT NULL, params JSON NULL, action_version INT NOT NULL DEFAULT 1, status ENUM('pending','running','done','failed','skipped','refused','cancelled') NOT NULL DEFAULT 'pending', due_at DATETIME NULL, attempts INT NOT NULL DEFAULT 0, -- Defaulted from the action's risk class at materialisation (§L): retry->skip -- for notify, retry->pause for change, retry->abort_run for irreversible. on_failure VARCHAR(32) NOT NULL DEFAULT 'skip', idempotency_key CHAR(40) NOT NULL, claimed_by VARCHAR(64) NULL, claim_expires_at DATETIME NULL, last_error VARCHAR(500) NULL, started_at DATETIME NULL, finished_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evstep_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, -- The drain scan, exactly as written: due, pending, oldest first. INDEX idx_evstep_due (status, due_at), -- The run console: every step of one run in authored order. INDEX idx_evstep_run (run_id, phase, seq), -- Materialisation is INSERT IGNORE against this, so a tick that overruns into -- the next one cannot double-materialise a phase. UNIQUE KEY uq_evstep_slot (run_id, phase, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- "Why didn't phase 3 start?" must be a query. -- -- `activity_log.detail` is TEXT and unqueryable, which is the whole reason this -- table exists rather than the audit log being reused: an operator diagnosing a -- stalled phase needs to filter by kind and read structured detail, and an -- administrative audit of WHO published WHAT is a different question with a -- different retention. Both are written — the audit to `activity_log`, the -- diagnosis here. -- -- `kind` is a closed set enforced in `eventRunLog.db.js` rather than an ENUM, -- because the set grows with almost every later phase and an ENUM change is a -- table alter this project has no migration system for. -- -- The log is high-cardinality and grows per event, so it needs a retention sweep -- from the start — `engagementRetentionPrune` is the pattern, and the rule that -- work learned is that only TERMINAL rows are eligible. The sweep itself lands -- with the runner in Phase 2; the index it needs is here from the beginning. CREATE TABLE IF NOT EXISTS event_run_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, step_id BIGINT NULL, kind VARCHAR(48) NOT NULL, phase VARCHAR(64) NULL, detail JSON NULL, at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_evlog_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, CONSTRAINT fk_evlog_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL, -- The run console reads this whole index and nothing else. INDEX idx_evlog_run (run_id, at), -- What the Phase 2 retention sweep queries. Without it the sweep is a table -- scan of every line this deployment has ever logged. INDEX idx_evlog_at (at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- What a phase is waiting for, and how far it has got (§E, Phase 5). -- -- A phase used to advance on one fact — every step terminal — and that fact -- lives in `event_run_steps`. An advance CONDITION is a second fact, and it is -- not derivable from any row that already exists: `{ on: 'uo.champ.boss_up', -- count: 3 }` is a tally of things that happened between one tick and the next, -- and the runner is not running when they happen. This table is where a firing -- is counted at the moment it fires. -- -- **One row per (run, phase), created at phase entry by INSERT IGNORE**, the -- same idempotence `materialisePhase` has and for the same reason: a process -- that died between entering a phase and writing this must not open a second -- gate on the next tick. -- -- **The tally is incremented by one statement with the threshold in it**, never -- read-then-written — the argument `event_run_budget`'s conditional increment -- makes, one phase early. Two emits arriving together each add one, and exactly -- one of them crosses `needed`. -- -- `last_event` holds ONLY the variables the condition names, not the payload. -- It exists to answer "what did the last one look like, and why did it not -- count", and a copy of a whole game event's data is a second copy of exactly -- the content `engagement_sends` is careful not to keep. -- -- `satisfied_by` is a VARCHAR rather than an ENUM for `event_run_log.kind`'s -- reason: the set can grow (an authored timeout was considered and declined for -- Phase 5) and this project has no migration system for a column alter. `kind` -- IS an ENUM, because §E closes it at two shapes. CREATE TABLE IF NOT EXISTS event_run_phase_gates ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, phase VARCHAR(64) NOT NULL, kind ENUM('after','on') NOT NULL, -- kind='after': normalised to seconds at save, so the runner never parses a -- duration string. `due_at` is entered_at + this, computed once at entry. after_seconds INT NULL, -- kind='on': the trigger being waited on and the predicate over its declared -- variables. `conditions` is NULL for "any firing of this trigger". trigger_id VARCHAR(96) NULL, conditions JSON NULL, needed INT NOT NULL DEFAULT 1, tally INT NOT NULL DEFAULT 0, entered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, due_at DATETIME NULL, last_event JSON NULL, last_event_at DATETIME NULL, satisfied_at DATETIME NULL, satisfied_by VARCHAR(16) NULL, -- 'condition' | 'elapsed' | 'forced' forced_by INT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evgate_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, CONSTRAINT fk_evgate_user FOREIGN KEY (forced_by) REFERENCES users(id) ON DELETE SET NULL, -- Entry is INSERT IGNORE against this. UNIQUE KEY uq_evgate_phase (run_id, phase), -- The emit path's only query: every open gate waiting on this trigger. It runs -- on every game event of every trigger anything waits on, so it is the one -- index in this feature that is on a hot path rather than an admin screen. INDEX idx_evgate_open (trigger_id, satisfied_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- ── Enablement, caps and the verify gate (EVENTS.md §D/§K — Phase 6) ─────── -- The deployment's switchboard, and the whole of the permission model beyond the -- role (§K layer 2). -- -- **Not a grant table.** Nobody is named in it, because the role check already -- answered who; this table answers *what this deployment permits at all*, and -- how much of it per run. That is the distinction §K draws between a capability -- and permission to invoke it: a module declaring `uo.creature.spawn` is code the -- operator installed, not a permission they granted. -- -- **A missing row is not "disabled" — it is "the default for its risk class".** -- Rows are written when an admin changes something, never seeded at boot, for a -- reason that is structural rather than tidy: the registry is assembled in -- `registerCore()` and by module `register()`, both of which run under -- `routeManifest.js` and `swagger.js` against a DEAD POOL (MODULE_API.md §2.2). -- A boot-time seed from the registry would be exactly the database write those -- two forbid. Reading a default from the risk class costs one branch and means a -- deployment that never opens this screen behaves correctly. -- -- The row survives its action: uninstalling a module leaves the settings behind, -- so re-installing it restores the caps the operator chose rather than silently -- resetting them. The switchboard only lists what is registered *now*, so a -- stranded row is invisible until its action comes back. -- -- `action_id` is the primary key rather than an id column: there is exactly one -- row per action and every read is by that id. CREATE TABLE IF NOT EXISTS event_action_settings ( action_id VARCHAR(96) NOT NULL PRIMARY KEY, enabled TINYINT(1) NOT NULL DEFAULT 0, -- `{dimension: perRunCap}`. A dimension absent from this object is uncapped by -- this action; an empty object is an action that declares no cost at all. caps JSON NULL, updated_by INT NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evset_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- What one run has spent, and the most it may. -- -- **The cap is COPIED here at run start, not read live.** A run is already -- reproducible in every other respect — it pins a version, and the version is -- immutable — and a cap read live would be the one input to a run's behaviour -- that an admin could change underneath it at three in the morning. Copying also -- makes the console's meter answer the right question afterwards: "what was this -- run allowed", not "what is allowed now". -- -- **One row per dimension, so the cap is the tightest of the actions the run's -- version names** (org lead, 2026-09-03). Two actions that both spend -- `uo.creatures` share this row, which is what makes a dimension a bound on the -- run's total effect rather than a per-verb allowance. `effective_from` records -- which action's cap won, so the console can say so. -- -- `consumed + ? <= cap` in the WHERE is the whole concurrency story (§E): two -- steps drawing on one dimension in the same tick cannot both see 28/30 and both -- spend, and no transaction is needed to say so. Same shape as the outbox claim -- and the gate's conditional increment, and the same reason. CREATE TABLE IF NOT EXISTS event_run_budget ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, dimension VARCHAR(96) NOT NULL, consumed INT NOT NULL DEFAULT 0, -- **NULL is uncapped, and it is a row rather than an absent one.** A dimension -- every action naming it left uncapped still accumulates here, so the console's -- meter can say "14 spawned, no cap" -- and so that a MISSING row keeps its one -- unambiguous meaning: a step spending a dimension its own run's version never -- priced, which `spend()` refuses. cap INT NULL, -- The action whose cap was the minimum. Documentary: it is what lets the run -- console say "30, from uo.creature.spawn" rather than showing a number the -- operator cannot trace back to a switch they set. effective_from VARCHAR(96) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evbud_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, -- Seeding is INSERT IGNORE against this, so a tick that overruns into the next -- one cannot double-seed a run's budget. UNIQUE KEY uq_evbud_dim (run_id, dimension) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- The cleanup ledger: everything one run created or leased, and what became of -- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8). -- -- **It holds both kinds of thing an event owns.** An OBJECT it created is -- `kind: 'creature'` with `ref` a serial, reverted by its own action's -- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and -- `payload` carrying the baseline and what was applied, restored by the lease's -- own `restore()`. One table, because cleanup asks both the same question -- -- what is still out there, and did putting it back work. -- -- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial -- does not exist until the module answers, so what is written before the dispatch -- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved -- '@step', `ref` = that key). On the answer the reported resources are inserted -- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the -- placeholder survives, and cleanup calls `revert()` with the idempotency key and -- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })` -- takes the key at all. Recording afterwards instead would make every object -- whose ack was lost invisible to cleanup for ever. -- -- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row -- that never reverts stays visible -- the run reaches `completed` with -- `cleanup_status = 'incomplete'` rather than being held `running`, because a -- tidy `completed` over a shard full of orphaned monsters is the failure that -- would end this feature's credibility on its first bad night. -- -- **The unique key is what stops two events leasing one target**, and it must -- hold among LIVE rows only: last week's finished event must not keep this -- week's from leasing the same rate. MariaDB has no partial index, so the -- encoding is a STORED generated column that is NULL once the row is no longer -- ours -- and multiple NULLs do not collide in a unique index. It is derived from -- `status` ALONE and the opaque columns stay in the KEY, which is the shape -- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a -- foreign key whose column is a base column of a stored generated column -- (error 1901), so `step_id` must not appear in the expression. -- -- **The key is held by the three statuses that mean "core still believes this is -- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that -- mean it is not. §D says "among non-reverted rows", which was written before the -- six statuses had their meanings; taken literally it makes `drifted` and -- `orphaned` hold a target for ever, so one bad night would disable a lease -- permanently with no control able to clear it. `drifted` means somebody else has -- hold of the value and this run has deliberately let go of it; `orphaned` means -- it vanished. Neither is a claim on the target, and both stay LOUD by another -- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console -- -- which is what §L's rule 2 actually asks for. Amended 2026-09-03. CREATE TABLE IF NOT EXISTS event_run_resources ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, -- Which step made it. It is how cleanup finds the ACTION to call `revert()` on: -- the row records the module and the opaque names, and the step records the -- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record -- of what was changed in the world must outlive the row that scheduled it. step_id BIGINT NULL, -- The registering module, copied at record time rather than derived from the -- action id, so an uninstalled module still names itself on the console. owner_module VARCHAR(64) NOT NULL, -- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s -- treatment. '@step' is the one reserved `kind` and core owns it. kind VARCHAR(64) NOT NULL, ref VARCHAR(190) NOT NULL, payload JSON NULL, -- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as -- well: the game side restores baseline when it passes, without being asked -- again, which is the fail-safe that makes an unattended world change -- defensible. This column is core's copy of that promise, for the console and -- for the boot-time check. lease_until DATETIME NULL, status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted') NOT NULL DEFAULT 'pending', -- Bounded like a step's `attempts`, and for the same reason: a revert that can -- never succeed must become visible rather than cycling for ever. Engagement -- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what -- makes an unbounded counter a row nothing can ever sweep. revert_attempts INT NOT NULL DEFAULT 0, last_error VARCHAR(500) NULL, -- Optional, and module-opaque like the rest: who received it, for a granted -- reward that results should be able to name. `event_run_participants` joins on -- the same key in Phase 10. member_key VARCHAR(190) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, -- 1 while core still believes this resource is this run's, NULL once it is not. -- See the unique key below; derived from `status` alone, deliberately. live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED, CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL, -- "Two events cannot hold a lease on one target", among non-reverted rows. UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker), -- The run console, and the cleanup sweep's read: one run's ledger in order. INDEX idx_evres_run (run_id, status), -- The cleanup leg's scan across runs, and the boot-time lease self-check. INDEX idx_evres_live (status, lease_until) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- §K's last bound: "a scheduled definition that has never been verified is the -- case worth refusing to start". A version is immutable, so a dry run that passed -- against it stays true — which is what makes the pass a property of the VERSION -- rather than of the definition, and what makes recording it two columns rather -- than a table. -- -- Enforced for SCHEDULED starts only (org lead, 2026-09-03): a human pressing -- start is watching, and that human is the review the gate exists to require. ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL; ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL; -- ── Integrations: participants, results and the run's announcements -- (EVENTS.md §D/§J — Phase 10) ───────────────────────────────────────────── -- Who took part, and how well. The eleventh and last of §D's core tables. -- -- **Core writes this table and never sources it.** A `member_key` is -- module-opaque, exactly like a resource's `ref`: core cannot map "Darrow of -- Britain" onto a user row and must not try, because the mapping is one game's -- (`shard_links`, for module-uo) and would be compiled into core the moment it -- guessed. A module that knows both halves supplies both — `memberKey` always, -- `userId` when its own link table has one — and core stores what it is told. -- -- `SET NULL` rather than `CASCADE`, matching `engagement_sends`: a record of what -- happened at an event has to survive the deletion of an account that attended -- it, or the results of last year's invasion silently rewrite themselves. -- -- **`rank` is NULL until results are published** and is computed then, by -- `core.results.publish`, over `score DESC`. It is a stored column rather than a -- window function in the read because a published result is a fact about a -- moment: a participant added afterwards (a late correction, a module's second -- collect step) must not silently renumber a table people have already read. CREATE TABLE IF NOT EXISTS event_run_participants ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id BIGINT NOT NULL, -- Module-opaque and NOT NULL: it is the identity the module knows, and the -- half of the unique key that makes a repeated collect idempotent. A run whose -- module cannot name its participants has no rows here at all. member_key VARCHAR(190) NOT NULL, user_id INT NULL, -- Signed, because a game may score downward as readily as upward, and DECIMAL -- rather than a float so two equal scores compare equal and a rank is stable. score DECIMAL(18,4) NOT NULL DEFAULT 0, rank_at INT NULL, joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Module-opaque. Whatever the module wants results to be able to display -- beside a name -- a class, a city, a kill count -- with no core vocabulary in -- it and nothing core ever reads. meta JSON NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_evpart_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE, CONSTRAINT fk_evpart_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL, -- One row per participant per run. What makes a module reporting the same set -- twice -- a retried collect step, a second attempt after a timeout -- an -- upsert rather than a duplicated leaderboard. UNIQUE KEY uq_evpart_member (run_id, member_key), -- The results table: one run, best first. INDEX idx_evpart_score (run_id, score), -- Profile history (`GET /player/events/history`, Phase 14), newest first. INDEX idx_evpart_user (user_id, joined_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- When the results table was published, and by which run of the publish action. -- -- A stamp rather than a status: a run either has published results or it has -- not, and the two questions a surface asks -- "may I show this table" and "when -- was it settled" -- are the same column. `core.results.publish` is idempotent -- against it (a re-run re-ranks and re-stamps), which is what makes it safe as an -- ordinary retried step. ALTER TABLE event_runs ADD COLUMN IF NOT EXISTS results_published_at DATETIME NULL; -- The run an announce job belongs to, when it belongs to one. -- -- **Nullable, and every existing row keeps NULL**: the news pipeline's jobs are -- not an event's, and nothing about how they are enqueued, retried or rolled up -- changes. What this buys is that `core.announce.post` may enqueue a SECOND job -- for a post that has already been announced -- the common case, since the post -- an event announces is very often the news post that announced it -- without -- either colliding with the first or overwriting `posts.announce_job_id`, which -- is the back-pointer the post admin panel's retry button reads. -- -- **No foreign key, exactly like `posts.announce_job_id` beside it.** An announce -- job that went out is a delivery record and must outlive whatever asked for it, -- and `ADD CONSTRAINT ... FOREIGN KEY` has no `IF NOT EXISTS` in MariaDB -- so a -- constraint here would be the one statement in this file that cannot replay. -- The column is read only to answer "which run announced this", and a run id -- that no longer resolves answers that honestly. ALTER TABLE announce_jobs ADD COLUMN IF NOT EXISTS run_id BIGINT NULL; ALTER TABLE announce_jobs ADD INDEX IF NOT EXISTS idx_announce_run (run_id);