feat(teams): the activity feed, its two writers and its retention

TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.

Core writing 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.

Three rules shape the model:

  - core never composes a summary. It arrives already rendered and is stored
    verbatim; core cannot phrase "gained 15,000 gold" for a game whose
    vocabulary it does not know.
  - visibility fails closed. An item with no stated visibility is `members`.
  - a push never throws at its call site. It is called from inside a game-event
    handler, and a storage problem of core's must not become the module's
    control flow.

Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.

Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.

The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:15:18 -05:00
parent 1f175786a7
commit aa332eda82
9 changed files with 1100 additions and 9 deletions

View File

@@ -1051,6 +1051,46 @@ CREATE TABLE IF NOT EXISTS team_moderation_requests (
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=<moduleId>. 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 <source>.<name>, 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;
-- 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.