Files
galaxy-game/lobby/PLAN.md
T
2026-04-25 23:20:55 +02:00

61 KiB
Raw Blame History

Game Lobby Service Implementation Plan

This plan has been already implemented and stays here for historical reasons.

It should NOT be threated as source of truth for service functionality.

Summary

This plan builds Game Lobby Service as the platform source of truth for game sessions, enrollment, membership, the game start sequence, and the platform-wide Race Name Directory (RND). It integrates synchronously with User Service for eligibility checks and Game Master for runtime registration, and asynchronously with Runtime Manager via Redis Streams for start jobs, with Game Master via Redis Streams for runtime updates, and with User Service via Redis Streams for user lifecycle events (permanent block, account deletion) that trigger cascade release of all race names owned by the user.

The RND supersedes the simple per-name reservation model of Stage 09. It owns two kinds of bindings:

  • registered — platform-unique permanent names owned by one user, count-bounded per tariff (max_registered_race_names);
  • reservation — per-game holding that survives until the game finishes, then either escalates to a 30-day pending_registration (when capability is satisfied) or is released immediately.

User Service simultaneously drops its single-valued race_name concept in favour of two stable fields: user_name (immutable auto-generated handle) and display_name (mutable free-form text). All anti-fraud canonicalization moves from User Service into Lobby RND.

Global Rules

  • Keep platform game state strictly in Game Lobby; never duplicate it in Game Master beyond the allowed denormalized snapshot.
  • Preserve all status transition invariants; no transition fires without an explicit allowed trigger.
  • Keep the Race Name Directory behind a port interface from the first commit.
  • RND is the sole platform source of truth for in-game race_name values. User Service owns user_name (immutable handle) and display_name (free text), never race_name.
  • RND reservations are keyed by (game_id, canonical_key). One user may hold the same name simultaneously across multiple active games. A name is considered taken for another user when any registered, active reservation, or pending_registration by a different user exists on the same canonical key.
  • Canonical key logic (lowercase + frozen confusable-pair policy) lives in lobby/internal/domain/racename/policy.go, not in User Service.
  • Post-game capability is evaluated by Lobby at game_finished: capable = max_planets > initial_planets AND max_population > initial_population. Capable reservations are moved to pending_registration with eligible_until = finished_at + 30 days; incapable reservations are released immediately.
  • Registration is user-initiated via lobby.race_name.register; it consumes one tariff slot. Tariff downgrade never revokes existing registrations.
  • Cascade release (RND.ReleaseAllByUser) runs when Lobby consumes a permanent_blocked or deleted event from user:lifecycle_events.
  • Never publish a notification intent after rolling back business state; always publish after successful commit.
  • Use synchronous internal REST only where the architecture document fixes a synchronous interaction (Game Lobby → User Service, Game Lobby → Game Master for registration).
  • Use Redis Streams for all other cross-service propagation.
  • Keep enrollment automation and pending-registration expiration idempotent; a second tick over the same conditions must produce no side effects.
  • Design Redis-backed stores behind port interfaces to keep a future SQL migration and a future dedicated Race Name Service possible. Replacing the RND adapter must require no domain or service changes.

Suggested Module Structure

lobby/
├── cmd/
│   └── lobby/
│       └── main.go
│
├── internal/
│   ├── app/
│   │   ├── runtime.go
│   │   ├── bootstrap.go
│   │   └── wiring.go
│   │
│   ├── config/
│   │   ├── config.go
│   │   ├── env.go
│   │   └── validation.go
│   │
│   ├── domain/
│   │   ├── game/
│   │   │   ├── model.go
│   │   │   ├── status.go
│   │   │   ├── transitions.go
│   │   │   └── errors.go
│   │   ├── application/
│   │   │   ├── model.go
│   │   │   ├── status.go
│   │   │   └── errors.go
│   │   ├── invite/
│   │   │   ├── model.go
│   │   │   ├── status.go
│   │   │   └── errors.go
│   │   ├── membership/
│   │   │   ├── model.go
│   │   │   ├── status.go
│   │   │   └── errors.go
│   │   └── common/
│   │       ├── ids.go
│   │       └── types.go
│   │
│   ├── domain/
│   │   ├── racename/
│   │   │   ├── policy.go
│   │   │   ├── policy_test.go
│   │   │   └── types.go
│   │   └── … (game, application, invite, membership, common as before)
│   │
│   ├── ports/
│   │   ├── gamestore.go
│   │   ├── applicationstore.go
│   │   ├── invitestore.go
│   │   ├── membershipstore.go
│   │   ├── racenamedir.go
│   │   ├── gameturnstatsstore.go
│   │   ├── userservice.go
│   │   ├── userlifecyclestream.go
│   │   ├── gmclient.go
│   │   └── runtimemanager.go
│   │
│   ├── adapters/
│   │   ├── redisstate/
│   │   │   ├── gamestore.go
│   │   │   ├── applicationstore.go
│   │   │   ├── invitestore.go
│   │   │   ├── membershipstore.go
│   │   │   ├── racenamedir.go
│   │   │   └── gameturnstatsstore.go
│   │   ├── racenamestub/
│   │   │   └── directory.go
│   │   ├── userservice/
│   │   │   └── client.go
│   │   └── gmclient/
│   │       └── client.go
│   │
│   ├── service/
│   │   ├── creategame/
│   │   ├── updategame/
│   │   ├── openenrollment/
│   │   ├── cancelgame/
│   │   ├── manualreadytostart/
│   │   ├── startgame/
│   │   ├── retrystartgame/
│   │   ├── pausegame/
│   │   ├── resumegame/
│   │   ├── submitapplication/
│   │   ├── approveapplication/
│   │   ├── rejectapplication/
│   │   ├── createinvite/
│   │   ├── redeeminvite/
│   │   ├── declineinvite/
│   │   ├── revokeinvite/
│   │   ├── removemember/
│   │   ├── blockmember/
│   │   ├── registerracename/
│   │   ├── listmyracenames/
│   │   ├── capabilityevaluation/
│   │   ├── getgame/
│   │   ├── listgames/
│   │   └── listmemberships/
│   │
│   ├── worker/
│   │   ├── enrollmentautomation/
│   │   ├── runtimejobresult/
│   │   ├── gmevents/
│   │   ├── pendingregistration/
│   │   └── userlifecycle/
│   │
│   ├── api/
│   │   ├── publichttp/
│   │   └── internalhttp/
│   │
│   ├── telemetry/
│   └── logging/
│
├── api/
│   ├── public-openapi.yaml
│   └── internal-openapi.yaml
│
├── README.md
├── PLAN.md
└── go.mod

Stage 01. Update ARCHITECTURE.md

Status: implemented as part of the planning task that produced this file.

Goal:

  • reconcile ARCHITECTURE.md with all decisions made during planning

Tasks:

  • Replace the Lobby status model block: remove enrollment_closed, add start_failed.
  • Add enrollment rules section documenting min_players, max_players, start_gap_hours, start_gap_players, enrollment_ends_at, and the three auto-transition paths.
  • Update private game joining rule: redeeming an invite creates active membership immediately without a separate owner-approval step.
  • Add Race Name Directory section.
  • Add Game Master → Game Lobby runtime snapshot stream to the fixed asynchronous interactions list.

Exit criteria:

  • ARCHITECTURE.md accurately reflects the status model, enrollment rules, Race Name Directory policy, and GM→Lobby transport used throughout this plan and README.

Stage 01R. ARCHITECTURE.md — Race Name Directory expansion

Status: implemented — see lobby/docs/stage01R-architecture-rnd-expansion.md and the corresponding updates across §3 User Service, §7 Game Lobby, §8 Game Master (new «Runtime snapshot publishing» subsection), §11 Billing Service, and «Fixed asynchronous interactions» in ARCHITECTURE.md.

Revision of Stage 01 for the two-tier Race Name Directory and the adjacent User Service refactor.

Tasks:

  • Rewrite §7 Race Name Directory section: registered vs reservation, canonical key with confusable-pair policy hosted in Lobby, capability gating at game finish, 30-day post-game registration window, cascade release on permanent_block / DeleteUser.
  • Update §3 User Service section: remove race_name from owned state; add user_name, display_name, permanent_block sanction, DeleteUser endpoint, max_registered_race_names in eligibility snapshot.
  • Update §7 and §8: runtime_snapshot_update in gm:lobby_events carries player_turn_stats (planets, population, ships_built per user); Lobby maintains per-game/per-user stats aggregate.
  • Update §11 Billing Service: tariff changes affect only new registrations.
  • Add User Service → Game Lobby to «Fixed asynchronous interactions» as user:lifecycle_events (permanent_blocked, deleted).

Exit criteria:

  • ARCHITECTURE.md matches the locked RND design; no contradictions with lobby/README.md or user/README.md.

Stage 02. Freeze Game Record Vocabulary

Status: implemented — see lobby/README.md sections Game Record Model through Enrollment Rules.

Goal:

  • eliminate all ambiguity in the game entity before writing Go code

Tasks:

  • Confirm all game record fields (names, types, validation rules) in README.md.
  • Confirm the full status set and every allowed transition with its trigger.
  • Confirm enrollment auto-transition logic (deadline, gap) in writing.
  • Confirm field immutability rules: which fields are editable in which statuses.

Exit criteria:

  • lobby/README.md sections Game Record Model, Status vocabulary, Status transition table, and Enrollment Rules contain no unresolved questions.

Stage 03. Freeze Invite, Application, and Membership Vocabulary

Status: implemented — see lobby/README.md sections Application Lifecycle, Invite Lifecycle, Membership Model, and Race Name Directory.

Goal:

  • lock the three participant entity schemas before writing persistence code

Tasks:

  • Confirm all fields for Application, Invite, and Membership in README.md.
  • Confirm state machines and allowed transitions for each entity.
  • Confirm that public games use applications only and private games use invites only.
  • Confirm Race Name Directory port interface signature and stub behavior.
  • Confirm that lobby.invite.revoked and lobby.invite.declined produce no notification in v1.

Exit criteria:

  • lobby/README.md sections Application Lifecycle, Invite Lifecycle, Membership Model, and Race Name Directory contain no unresolved questions.
  • Notification intent shapes in README.md are consistent with the frozen catalog in notification/README.md.

Stage 03R. README.md — Two-tier RND + stats + new APIs

Status: implemented — see lobby/docs/stage03R-readme-rnd-surface.md and lobby/README.md sections Race Name Directory, Membership Model, Runtime Snapshot, Notification Contracts, Error Model, Configuration, Redis Logical Model, and Observability.

Revision of Stage 03.

Tasks:

  • Rewrite §Race Name Directory: port interface with Canonicalize, Check, Reserve(game_id, user_id, race_name), ReleaseReservation, MarkPendingRegistration, ExpirePendingRegistrations, Register, ListRegistered, ListPendingRegistrations, ListReservations, ReleaseAllByUser; new sentinel errors.
  • Update §Membership Model to store canonical_key alongside race_name.
  • Update §Runtime Snapshot: add player_turn_stats (initial + current planets, population, ships_built per user). Lobby caches aggregates under lobby:game_turn_stats:<game_id>:<user_id>.
  • Add §Race Name Registration flow:
    • capability evaluation at game_finished;
    • pending_registration window = 30 days;
    • lobby.race_name.register message type with tariff + capability checks;
    • fast-path self-service read lobby.race_names.list.
  • Extend §Notification Contracts with lobby.race_name.registration_eligible, lobby.race_name.registered, lobby.race_name.registration_denied (optional).
  • Extend §Error Model with race_name_registration_quota_exceeded, race_name_pending_window_expired, race_name_capability_not_met, race_name_permanent_blocked.
  • Extend §Configuration: LOBBY_RACE_NAME_EXPIRATION_INTERVAL default 1h, LOBBY_PENDING_REGISTRATION_TTL_HOURS default 720, LOBBY_USER_LIFECYCLE_STREAM default user:lifecycle_events, LOBBY_RACE_NAME_DIRECTORY_BACKEND default redis.
  • Extend §Redis Logical Model with RND keys (lobby:race_names:registered:<canonical_key>, lobby:race_names:reservations:<game_id>:<canonical_key>, lobby:race_names:user_registered:<user_id>, lobby:race_names:user_reservations:<user_id>, lobby:race_names:pending_index, lobby:race_names:canonical_lookup:<canonical_key>, lobby:game_turn_stats:<game_id>:<user_id>).
  • Extend §Observability with lobby.race_name.outcomes, lobby.pending_registration.expirations, lobby.user_lifecycle.cascade_releases.

Exit criteria:

  • lobby/README.md describes the full RND surface; downstream code stages can reference it without further ambiguity.

Stage 04. Define OpenAPI Contracts

Status: implemented — see lobby/api/public-openapi.yaml, lobby/api/internal-openapi.yaml, and lobby/docs/stage04-openapi-decisions.md.

Goal:

  • produce stable REST contract files before wiring HTTP handlers

Tasks:

  • Add lobby/api/public-openapi.yaml covering all message types from the message type catalog in README.md.
  • Add lobby/api/internal-openapi.yaml covering GM registration and admin endpoints.
  • Freeze request and response shapes for all routes.
  • Document authorization expectations per route (admin, owner, any member, etc.).

Exit criteria:

  • both OpenAPI files are syntactically valid and cover every route in the message type catalog.

Stage 05. Freeze Notification Intent Publishing Rules

Status: implemented — see lobby/README.md Notification Contracts section and the existing galaxy/notificationintent module.

Goal:

  • confirm all notification triggers before service and worker code touches galaxy/notificationintent

Tasks:

  • Map every trigger in README.md Notification Contracts to the correct constructor in galaxy/notificationintent.
  • Confirm that NewPublicLobbyApplicationSubmittedIntent is the only path for lobby.application.submitted in v1.
  • Confirm lobby.invite.expired is published per-invite (not batched into one intent).

Exit criteria:

  • every notification trigger has an identified constructor in galaxy/notificationintent; no new constructor is needed for v1.

Stage 06. Module Skeleton

Status: implemented — see lobby/cmd/lobby, lobby/internal/{config,logging,telemetry,app,api/publichttp,api/internalhttp}, and lobby/docs/stage06-skeleton-decisions.md.

Goal:

  • create the runnable service process with no business logic

Tasks:

  • Add go.mod dependencies: redis/go-redis/v9 with redisotel, the go.opentelemetry.io/otel v1.43 stack with otelhttp, testcontainers-go together with modules/redis, alicebob/miniredis/v2, and stretchr/testify. The skeleton uses the Go standard library net/http; no web framework is added. This mirrors the dependency set used by mail and notification.
  • Add cmd/lobby/main.go with signal handling and context propagation.
  • Add internal/config/ with env loading, validation, and DefaultConfig().
  • Add internal/app/runtime.go: Redis startup check, structured logger, telemetry provider, graceful shutdown, composed through the generic app.Component lifecycle in internal/app/app.go and helpers in internal/app/bootstrap.go.
  • Add internal/api/publichttp/ and internal/api/internalhttp/ routers with GET /healthz and GET /readyz only.
  • Wire both HTTP listeners in app/runtime.go through app.New(...).

Exit criteria:

  • go build ./... succeeds with no errors.
  • go test ./... passes (no tests yet beyond smoke).
  • process starts with a valid Redis address and serves /healthz on both ports.
  • process exits cleanly on SIGTERM.

Stage 07. Game Domain Model and Redis Store

Status: implemented — see lobby/internal/domain/{common,game}, lobby/internal/ports/gamestore.go, lobby/internal/adapters/redisstate/, and lobby/docs/stage07-game-store-decisions.md.

Goal:

  • implement the game entity with status enforcement and Redis persistence

Tasks:

  • Add internal/domain/game/model.go: all game fields, value types, constructor New(...) that validates all required fields.
  • Add internal/domain/game/status.go: Status type, all status constants, AllowedTransitions map, Transition(from, to, trigger) function that returns an error for invalid transitions.
  • Add internal/domain/game/errors.go: sentinel and typed errors (ErrNotFound, ErrConflict, ErrInvalidTransition).
  • Add internal/ports/gamestore.go: port interface (Get, GetByStatus, Save, UpdateStatus, UpdateRuntimeSnapshot).
  • Add internal/adapters/redisstate/gamestore.go: Redis implementation using JSON serialization.
  • Add internal/adapters/redisstate/gamestore_test.go: tests using miniredis; cover create, get, update, status transition, snapshot update.

Exit criteria:

  • all game store tests pass with go test ./... -race.
  • invalid status transitions return an error at the domain level without touching the store.

Stage 08. Application, Invite, and Membership Stores

Status: implemented — see lobby/internal/domain/{application,invite,membership}, lobby/internal/ports/{applicationstore,invitestore,membershipstore}.go, lobby/internal/adapters/redisstate/{codecs_application,codecs_invite,codecs_membership,applicationstore,invitestore,membershipstore}.go, and lobby/docs/stage08-store-decisions.md.

Goal:

  • add Redis-backed persistence for the three participant entities

Tasks:

  • Add domain packages: internal/domain/application/, internal/domain/invite/, internal/domain/membership/ each with model.go, status.go, errors.go.
  • Add port interfaces: internal/ports/applicationstore.go, internal/ports/invitestore.go, internal/ports/membershipstore.go.
  • Add Redis adapters for each entity under internal/adapters/redisstate/.
  • Add tests for each adapter using miniredis.
  • Enforce single active application per user per game at the store level.

Exit criteria:

  • all three entity types persist, load, and list correctly.
  • go test ./... -race passes.

Stage 09. Race Name Directory Port and Stub

Status: implemented — see lobby/internal/ports/racenamedir.go, lobby/internal/adapters/racenamestub/, lobby/internal/app/wiring.go, and lobby/docs/stage09-racenamedir-decisions.md.

Goal:

  • wire the Race Name Directory abstraction from the start so no code ever imports a concrete implementation directly

Tasks:

  • Add internal/ports/racenamedir.go: RaceNameDirectory interface (Reserve, Release, Check) with ErrNameTaken sentinel.
  • Add internal/adapters/racenamestub/directory.go: in-memory sync.Map implementation.
  • Wire the stub in internal/app/wiring.go.
  • Add unit tests for the stub covering reserve, release, check, and uniqueness invariant.

Exit criteria:

  • racenamestub tests pass.
  • all future service code refers to ports.RaceNameDirectory; no direct reference to racenamestub outside the wiring layer.

Stage 09R. Race Name Directory: two-tier model and Redis adapter

Status: implemented — see lobby/docs/stage09R-racenamedir-decisions.md, lobby/internal/ports/racenamedir.go, lobby/internal/adapters/redisstate/racenamedir.go (with racenamedir_lua.go and codecs_racename.go), lobby/internal/adapters/racenamestub/directory.go, lobby/internal/ports/racenamedirtest/suite.go, and the RaceNameDirectoryConfig group wired through internal/config/{config,env,validation}.go and internal/app/wiring.go.

Replaces Stage 09's port and stub with the two-tier directory. Depends on Stage 21 so the confusable-pair policy can be lifted out of User Service without churn.

Tasks:

  • Rewrite lobby/internal/ports/racenamedir.go under the new interface (see Stage 03R) with sentinels ErrNameTaken, ErrPendingExpired, ErrPendingMissing, ErrInvalidName, ErrQuotaExceeded.
  • Add lobby/internal/domain/racename/policy.go: canonical key generation (lowercase + frozen confusable-pair rules ported from user/internal/ports/race_name_policy.go), ValidateTypeName integration from pkg/util.
  • Implement lobby/internal/adapters/redisstate/racenamedir.go atop the Redis key layout in Stage 03R; tests use miniredis.
  • Rewrite lobby/internal/adapters/racenamestub/directory.go against the new interface so unit tests that do not need Redis stay fast.
  • Wire adapter selection in internal/app/wiring.go via LOBBY_RACE_NAME_DIRECTORY_BACKEND (redis default, stub for tests).
  • Port the User Service RaceNameReservation/RaceNamePolicy tests and their golden fixtures to lobby/internal/domain/racename/.

Exit criteria:

  • Redis adapter and stub both pass the same behavioural test suite (interface-level table tests).
  • Idempotent Reserve by the same user under the same game returns nil.
  • Check exposes (taken, holder_user_id, kind) consistent with Redis state.
  • MarkPendingRegistration leaves the existing reservation accessible to ListPendingRegistrations and to ExpirePendingRegistrations.
  • ReleaseAllByUser clears every registered, reservation, and pending entry for a user atomically (Lua or pipelined transaction).
  • confusable-pair test fixtures from user/internal/adapters/… run in the new package unchanged.

Stage 10. Game Creation and Draft Management

Status: implemented — see lobby/docs/stage10-game-lifecycle-decisions.md, lobby/internal/service/{shared,creategame,updategame,openenrollment,cancelgame}, lobby/internal/ports/idgenerator.go, lobby/internal/adapters/{idgen,gamestub}, and the extended lobby/internal/api/{publichttp,internalhttp} handlers.

Goal:

  • implement the initial game lifecycle operations with no enrollment logic yet

Tasks:

  • Add internal/service/creategame/: validate all game fields, create game in draft status, store via GameStore.
  • Add internal/service/updategame/: allow edits on draft and selected fields on enrollment_open; reject all other statuses.
  • Add internal/service/openenrollment/: draft → enrollment_open with admin/owner authorization check.
  • Add internal/service/cancelgame/: cancel from draft, enrollment_open, ready_to_start, start_failed; reject from starting, running, paused.
  • Wire all four service calls to routes on both HTTP ports.
  • Add service-level tests (in-memory stores, no Redis).

Exit criteria:

  • game creation, update, open-enrollment, and cancel all pass tests.
  • unauthorized callers receive forbidden.
  • invalid transition attempts return conflict.

Stage 11. Application Flow (Public Games)

Status: implemented — see lobby/docs/stage11-application-flow-decisions.md, lobby/internal/service/{submitapplication,approveapplication,rejectapplication}, lobby/internal/ports/{userservice,intentpublisher,gapactivationstore}.go, lobby/internal/adapters/userservice/, lobby/internal/adapters/redisstate/gapactivationstore.go, lobby/internal/adapters/{applicationstub,membershipstub,gapactivationstub,userservicestub,intentpubstub}/, the Membership.canonical_key field across lobby/internal/domain/membership/model.go and lobby/internal/adapters/redisstate/codecs_membership.go, the NewApplicationID/NewMembershipID extensions to lobby/internal/adapters/idgen/, and the new application routes wired through lobby/internal/api/{publichttp,internalhttp}/applications.go.

Goal:

  • implement the full public-game application lifecycle

Tasks:

  • Add internal/ports/userservice.go: UserService interface (GetEligibility(ctx, userID) (Eligibility, error)).
  • Add internal/adapters/userservice/client.go: HTTP client hitting GET /api/v1/internal/users/{user_id}/eligibility.
  • Add internal/service/submitapplication/:
    • game type must be public and status enrollment_open
    • call UserService.GetEligibility; fail if can_join_game=false
    • call RaceNameDirectory.Check(raceName, actorUserID); fail if name is taken by another user (returns name_taken) or permanent-blocked
    • create Application{status: submitted, canonical_key}
    • publish lobby.application.submitted intent via galaxy/notificationintent
  • Add internal/service/approveapplication/:
    • call RaceNameDirectory.Reserve(gameID, userID, raceName); idempotent
    • create Membership{status: active, canonical_key}
    • set application status=approved
    • publish lobby.membership.approved intent
    • trigger gap window open if approved_count == max_players
  • Add internal/service/rejectapplication/:
    • call RaceNameDirectory.ReleaseReservation(gameID, userID, raceName) — safe no-op when no reservation exists for the pair
    • set application status=rejected
    • publish lobby.membership.rejected intent
  • Wire routes.
  • Add service tests with in-memory stores, stubbed UserService, and stub RaceNameDirectory.

Exit criteria:

  • all three application operations pass tests.
  • eligibility denial surfaces as eligibility_denied error.
  • name conflict surfaces as name_taken error.
  • all three notifications are published in success paths.

Stage 12. Invite Flow (Private Games)

Status: implemented — see lobby/docs/stage12-invite-flow-decisions.md, lobby/internal/service/{createinvite,redeeminvite,declineinvite,revokeinvite}, the new NewInviteID extension across lobby/internal/ports/idgenerator.go and lobby/internal/adapters/idgen/, the in-process lobby/internal/adapters/invitestub/ test adapter, and the four invite routes wired through lobby/internal/api/publichttp/invites.go.

Goal:

  • implement the full private-game invite lifecycle

Tasks:

  • Add internal/service/createinvite/:
    • game type must be private, status enrollment_open
    • invitee must not have an active invite or active membership in the game
    • create Invite{status: created, expires_at: game.enrollment_ends_at}
    • publish lobby.invite.created intent
  • Add internal/service/redeeminvite/:
    • invite status must be created, game status enrollment_open
    • call RaceNameDirectory.Check(raceName, actorUserID); fail if name is taken by another user
    • call RaceNameDirectory.Reserve(gameID, userID, raceName)
    • create Membership{status: active, canonical_key}
    • set invite status=redeemed
    • publish lobby.invite.redeemed intent to owner
    • trigger gap window open if approved_count == max_players
  • Add internal/service/declineinvite/: set status=declined; no notification.
  • Add internal/service/revokeinvite/: set status=revoked; no notification.
  • Wire routes.
  • Add service tests.

Exit criteria:

  • redeem creates active membership without a separate approval step.
  • race name is reserved atomically before membership creation.
  • lobby.invite.created and lobby.invite.redeemed are published.
  • decline and revoke produce no notification.

Stage 13. Enrollment Automation Worker

Status: implemented — see lobby/docs/stage13-enrollment-automation-decisions.md, lobby/internal/worker/enrollmentautomation/, lobby/internal/service/manualreadytostart/, lobby/internal/service/shared/closeenrollment.go, the GapActivationStore.Get extension across lobby/internal/ports/gapactivationstore.go, lobby/internal/adapters/redisstate/gapactivationstore.go, and lobby/internal/adapters/gapactivationstub/store.go, plus the POST /api/v1/lobby/games/{game_id}/ready-to-start routes wired on both ports through lobby/internal/api/{publichttp,internalhttp}/ready_to_start.go and the worker registration in lobby/internal/app/{wiring,runtime}.go.

Goal:

  • implement all automatic enrollment-to-ready-to-start transitions

Tasks:

  • Add internal/worker/enrollmentautomation/worker.go:
    • periodic ticker with LOBBY_ENROLLMENT_AUTOMATION_INTERVAL (default 30s)
    • on each tick, load all games in enrollment_open status
    • for each game check:
      1. deadline: now >= enrollment_ends_at && approved_count >= min_players
      2. gap exhaustion: gap window is open and (now >= gap_activated_at + start_gap_hours or approved_count >= max_players + start_gap_players)
    • on transition to ready_to_start:
      • atomically expire all created invites for the game
      • publish lobby.invite.expired intents (one per expired invite)
  • Add internal/service/manualreadytostart/:
    • admin/owner command
    • require approved_count >= min_players
    • same expiry and notification side effects as auto-transition
  • Add gap window activation: when approved_count reaches max_players, record gap_activated_at in Redis.
  • Add tests using a fake clock; cover all three auto-transition paths and the boundary condition where the deadline fires but min_players is not yet met.

Exit criteria:

  • all three auto-transition paths are covered by tests.
  • invite expiry on enrollment close is tested.
  • the worker is idempotent: running twice over the same state produces no duplicate transitions or notifications.

Stage 14. Game Start Flow

Status: implemented — see lobby/docs/stage14-game-start-flow-decisions.md, lobby/internal/ports/{runtimemanager,gmclient,streamoffsetstore}.go, the RuntimeBinding field on lobby/internal/domain/game/model.go and the new GameStore.UpdateRuntimeBinding port method, lobby/internal/adapters/{runtimemanager,gmclient,redisstate/streamoffsetstore.go,runtimemanagerstub,gmclientstub,streamoffsetstub}/, lobby/internal/service/{startgame,retrystartgame}/, lobby/internal/worker/runtimejobresult/, the LOBBY_RUNTIME_STOP_JOBS_STREAM env var, the public/internal start and retry-start HTTP routes, the removal of the obsolete register-runtime endpoint from lobby/api/internal-openapi.yaml, and the runtime_binding schema addition on the GameRecord shape across both OpenAPI contracts.

Goal:

  • implement the full start sequence spanning Runtime Manager and Game Master

Tasks:

  • Add internal/ports/runtimemanager.go: RuntimeManager interface (PublishStartJob(ctx, gameID string) error, PublishStopJob(ctx, gameID string) error).
  • Add Redis stream adapter for RuntimeManager (write-only; publishes to runtime:start_jobs).
  • Add internal/ports/gmclient.go: GMClient interface (RegisterGame(ctx, req RegisterGameRequest) error).
  • Add internal/adapters/gmclient/client.go: HTTP client for GM registration.
  • Add internal/service/startgame/:
    • validate ready_to_start
    • set status → starting
    • publish start job to RuntimeManager
  • Add internal/worker/runtimejobresult/consumer.go:
    • consume runtime:job_results stream
    • on failure result: set status → start_failed
    • on success result:
      • persist runtime_binding metadata on game record
      • call GMClient.RegisterGame synchronously
      • on GM success: set status → running; set started_at
      • on GM failure/timeout: set status → paused; publish lobby.runtime_paused_after_start intent
    • on metadata persistence failure before GM call: publish stop job to RuntimeManager; set status → start_failed
  • Add internal/service/retrystartgame/: start_failed → ready_to_start.
  • Wire consumer in app/runtime.go.
  • Add tests with stubbed RuntimeManager and GMClient; cover all four outcome paths.

Exit criteria:

  • success path: game reaches running after container start and GM registration.
  • paused path: GM unavailability produces paused + admin notification.
  • failure path: container failure produces start_failed.
  • orphan container path: metadata failure triggers stop job before start_failed.
  • all paths covered by go test ./... -race.

Stage 14A. Initial Player Stats Capture

Status: implemented — see lobby/docs/stage14A-game-turn-stats-decisions.md, lobby/internal/ports/gameturnstatsstore.go, lobby/internal/adapters/redisstate/{gameturnstatsstore,codecs_gameturnstats}.go, and lobby/internal/adapters/gameturnstatsstub/.

Goal:

  • freeze per-user initial_planets / initial_population at the first runtime_snapshot_update after starting → running

Tasks:

  • Add internal/ports/gameturnstatsstore.go: GameTurnStatsStore with SaveInitial(ctx, gameID, stats []PlayerInitialStats) error, UpdateMax(ctx, gameID, stats []PlayerObservedStats) error, Load(ctx, gameID) (GameTurnStatsAggregate, error), Delete(ctx, gameID) error (invoked after capability evaluation).
  • Add internal/adapters/redisstate/gameturnstatsstore.go keyed under lobby:game_turn_stats:<game_id>:<user_id>; tests with miniredis.
  • Extend the GM event DTO in internal/worker/gmevents/ to decode player_turn_stats.
  • In the consumer, invoke SaveInitial once per game (no-op on subsequent calls to preserve the first observation) and UpdateMax on every runtime_snapshot_update.

Exit criteria:

  • Initial stats do not change on subsequent snapshots.
  • UpdateMax uses per-metric max semantics (never decreases).
  • Idempotent replay of the GM stream produces the same aggregate.

Stage 15. GM Runtime Stream Consumer

Status: implemented — see lobby/docs/stage15-gm-events-consumer-decisions.md and lobby/internal/worker/gmevents/consumer.go. The consumer wires the existing LOBBY_GM_EVENTS_STREAM and LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT configuration through lobby/internal/app/{wiring,runtime}.go and hands off to the Stage 15A capability evaluator on game_finished.

Goal:

  • keep the denormalized runtime snapshot current using GM events and feed Stage 14A stats + Stage 15A capability evaluation

Tasks:

  • Add internal/worker/gmevents/consumer.go:
    • consume gm:lobby_events stream
    • on runtime_snapshot_update event:
      • call GameStore.UpdateRuntimeSnapshot (turn, status, health)
      • call GameTurnStatsStore.SaveInitial (first call only) and UpdateMax using player_turn_stats (Stage 14A)
    • on game_finished event:
      • apply final snapshot; transition game to finished; set finished_at
      • hand off to Stage 15A capability evaluator before acknowledging offset
    • advance stream offset only after successful processing
  • Add tests using miniredis with fake events; cover snapshot update, game_finished, and replay idempotency.

Exit criteria:

  • snapshot updates are applied without changing game status.
  • game_finished transitions game to finished, sets finished_at, and drives capability evaluation before offset advance.
  • consumer restarts from the persisted offset without double-processing stats.

Stage 15A. Capability Evaluation at Game Finish

Status: implemented — see lobby/docs/stage15A-capability-evaluation-decisions.md, lobby/internal/service/capabilityevaluation/service.go, lobby/internal/ports/evaluationguardstore.go, lobby/internal/adapters/redisstate/evaluationguardstore.go, and lobby/internal/adapters/evaluationguardstub/. Race-name notification intents are wired through the RaceNameIntents port-shim and bound to capabilityevaluation.NoopRaceNameIntents{} until Stage 24 lands the real publisher.

Goal:

  • decide per-member capability and resolve each active reservation into pending_registration or immediate release when a game finishes

Tasks:

  • Add internal/service/capabilityevaluation/service.go:
    • input: finished game id, final stats aggregate from GameTurnStatsStore
    • for each active membership:
      • capable = max_planets > initial_planets AND max_population > initial_population
      • capable ⇒ RND.MarkPendingRegistration(gameID, userID, raceName, finished_at + 30 days) + intent lobby.race_name.registration_eligible
      • not capable ⇒ RND.ReleaseReservation(gameID, userID, raceName) + (optional) intent lobby.race_name.registration_denied
    • for removed / blocked memberships with outstanding reservations: release immediately
    • delete GameTurnStatsStore aggregate for the game after evaluation
  • Hook the evaluator into gmevents consumer after game_finished processing.
  • Tests for capable / not-capable / mixed rosters, and for idempotency on replay.

Exit criteria:

  • every active membership of a finished game produces exactly one RND side effect (mark pending or release).
  • replayed game_finished events do not mutate RND state after the first successful evaluation (idempotency guard keyed on game id).
  • intents publish only after the RND mutation commits.

Stage 16. Paused State Management

Status: implemented — see lobby/docs/stage16-paused-state-decisions.md, lobby/internal/service/{pausegame,resumegame}/, the Ping extension on lobby/internal/ports/gmclient.go together with its real (lobby/internal/adapters/gmclient/client.go) and stub (lobby/internal/adapters/gmclientstub/client.go) implementations, and the public/internal pause/resume handlers wired through lobby/internal/api/{publichttp,internalhttp}/pause_resume.go, lobby/internal/app/{wiring,runtime}.go.

Goal:

  • implement voluntary pause and resume

Tasks:

  • Add internal/service/pausegame/:
    • actor must be admin or owner
    • game must be running
    • transition to paused
  • Add internal/service/resumegame/:
    • actor must be admin or owner
    • game must be paused
    • perform a synchronous GM liveness check (GMClient.Ping or equivalent)
    • on GM reachable: transition to running
    • on GM unreachable: return service_unavailable; game remains paused
  • Wire routes.
  • Add tests; cover GM-unreachable resume attempt.

Exit criteria:

  • pause and resume operations enforce authorization and status invariants.
  • resume does not transition to running when GM is unavailable.

Stage 17. Member Operations

Status: implemented — see lobby/docs/stage17-member-operations-decisions.md, lobby/internal/service/{removemember,blockmember}, the MembershipStore.Delete extension across lobby/internal/ports/membershipstore.go, lobby/internal/adapters/redisstate/membershipstore.go, and lobby/internal/adapters/membershipstub/store.go, plus the public/internal remove/block handlers wired through lobby/internal/api/{publichttp,internalhttp}/memberships.go and lobby/internal/app/{wiring,runtime}.go.

Goal:

  • implement member removal and block

Tasks:

  • Add internal/service/removemember/:
    • before game start: drop membership; call RND.ReleaseReservation(gameID, userID, raceName)
    • after game start: set membership status=removed; keep the reservation intact so game_finished evaluation decides its fate (Stage 15A)
  • Add internal/service/blockmember/:
    • set membership status=blocked
    • race name reservation is preserved; Stage 15A releases it at game_finished
  • Wire routes.
  • Add tests; cover pre-start and post-start removal semantics, including the interaction with Stage 15A for post-start remove/block.

Exit criteria:

  • removal before start releases the reservation immediately.
  • removal/block after start keeps the reservation until game_finished and Stage 15A releases it.

Stage 17A. Race Name Registration Service

Status: implemented — see lobby/docs/stage17A-race-name-registration-decisions.md, lobby/internal/service/registerracename/, lobby/internal/api/publichttp/racenames.go, the writeErrorFromService extension in lobby/internal/api/publichttp/games.go (with the new shared.ErrSubjectNotFound sentinel), the public OpenAPI surface update in lobby/api/public-openapi.yaml, and the wiring through lobby/internal/app/{wiring,runtime}.go.

Goal:

  • let a player convert a pending_registration reservation into a permanent registered race name

Tasks:

  • Add internal/service/registerracename/:
    • input: {race_name, source_game_id}; acting user from X-User-ID
    • preconditions:
      • canonical-key pending_registration exists for (source_game_id, user_id) with eligible_until > now
      • UserService.GetEligibility snapshot: active max_registered_race_names > current registered count (0 denotes unlimited); can_update_profile is not required
      • no permanent_block on the user
    • commit: RND.Register(source_game_id, user_id, race_name); emit intent lobby.race_name.registered
  • Wire route POST /api/v1/lobby/race-names/register on the public port.
  • Tests: happy path, quota exceeded (race_name_registration_quota_exceeded), pending expired (race_name_pending_window_expired), pending missing (subject_not_found), permanent-blocked user (forbidden).

Exit criteria:

  • Register call is atomic relative to RND reservation state.
  • Quota logic matches the snapshot semantics (free=1, monthly=2, yearly=6, lifetime=0 marker for unlimited).
  • Intent emits only after successful commit.

Stage 17B. Pending Registration Expiration Worker

Status: implemented — see lobby/docs/stage17B-pending-registration-worker-decisions.md, lobby/internal/worker/pendingregistration/, the new PendingRegistrationConfig group threaded through lobby/internal/config/{config,env,validation}.go, and the worker registration in lobby/internal/app/{wiring,runtime}.go.

Goal:

  • release every pending_registration whose eligible_until has passed

Tasks:

  • Add internal/worker/pendingregistration/worker.go:
    • ticker with LOBBY_RACE_NAME_EXPIRATION_INTERVAL (default 1h)
    • call RND.ExpirePendingRegistrations(now)
    • for each expired entry: release the reservation and increment lobby.pending_registration.expirations
    • no notification (informational only)
  • Tests using a fake clock and miniredis: boundary exactly at eligible_until, batch of mixed-age entries, idempotent second tick.

Exit criteria:

  • running the worker twice over the same state produces no extra side effects.
  • Stage 17A users who act before expiration still succeed (no race with the worker).

Stage 17C. Race Name Self-Service Reads

Status: implemented — see lobby/docs/stage17C-race-name-self-service-decisions.md, lobby/internal/service/listmyracenames/, the new GET /api/v1/lobby/my/race-names route on lobby/internal/api/publichttp/racenames.go with the Dependencies.ListMyRaceNames field on lobby/internal/api/publichttp/server.go, the wiring through lobby/internal/app/{wiring,runtime}.go, the MyRaceNamesResponse/ PendingRaceName/RaceNameReservation schemas added to lobby/api/public-openapi.yaml (with the matching TestPublicSpecFreezesMyRaceNamesContract in lobby/contract_openapi_test.go), and the expanded lobby/README.md §Race Name self-service section.

Goal:

  • give the acting user a single view of their registered / pending / active reservations

Tasks:

  • Add internal/service/listmyracenames/:
    • returns {registered[], pending[], reservations[]}
    • pending carries eligible_until_ms and source_game_id
    • reservations carries game_id and current game_status
  • Wire GET /api/v1/lobby/my/race-names; update public-openapi.yaml.
  • Visibility test: a user cannot read another user's RND state through this endpoint.

Exit criteria:

  • response shape matches lobby/README.md §Race Name self-service.
  • operation avoids scanning the full RND (uses user_registered / user_reservations indexes).

Stage 18. Query and Read APIs

Status: implemented — see lobby/docs/stage18-query-and-read-apis-decisions.md, the six new service packages lobby/internal/service/{getgame,listgames,listmemberships,listmygames, listmyapplications,listmyinvites}/, the shared pagination helper lobby/internal/service/shared/page.go, the public-port handlers in lobby/internal/api/publichttp/{games,memberships,mylists}.go, the internal-port handlers in lobby/internal/api/internalhttp/{games,memberships}.go, and the wiring updates in lobby/internal/app/{wiring,runtime}.go.

Goal:

  • implement all user-facing list and read operations with visibility enforcement

Tasks:

  • Add internal/service/getgame/:
    • enforce visibility rules: private game hidden from non-member non-owner users
    • return runtime snapshot from denormalized fields
  • Add internal/service/listgames/:
    • public list: enrollment_open, ready_to_start, running, finished only
    • authenticated user also sees their private game memberships
  • Add internal/service/listmemberships/:
    • admin, owner, or active member may list memberships of a game
  • Wire lobby.my_games.list, lobby.my_applications.list, and lobby.my_invites.list routes.
  • Add tests for visibility rules.

Exit criteria:

  • private game is not returned for non-member non-owner callers.
  • public draft game is excluded from the public list.
  • lists return correct entities for the authenticated user.

Stage 19. Observability

Status: implemented — see lobby/docs/stage19-observability-decisions.md, the extended lobby/internal/telemetry/runtime.go (15 instruments + 4 observable gauges + RegisterGauges), the new IntentPublisher / RaceNameDirectory metric decorators in lobby/internal/adapters/{metricsintentpub,metricsracenamedir}/, the GameStore.CountByStatus extension across lobby/internal/ports/gamestore.go and the redisstate / gamestub adapters, the new lobby/internal/ports/streamlagprobe.go port with the redisstate adapter and stub, the httpcommon.RequestID middleware wired on both HTTP listeners, the logging.ContextAttrs helper plus the trace_id / span_id rename in logging/logger.go, and the service / worker threading of *telemetry.Runtime through every status-transition / outcome / cascade success path.

Goal:

  • instrument the service for operational support

Tasks:

  • Add counters and gauges listed in README.md Observability section using the OpenTelemetry SDK.
  • Add structured log fields for all key operations (transitions, notification publishes, enrollment automation triggers, stream consumer events).
  • Propagate request_id and trace_id through all service calls and into structured logs where available.

Exit criteria:

  • process exports all listed metrics when a real or stdout OTEL exporter is configured.
  • key operations produce log entries with stable field names.

Stage 20. Test Coverage and Documentation Alignment

Status: implemented — see lobby/docs/stage20-test-coverage-and-doc-alignment-decisions.md, the new integration/lobbyuser/ package (4 boundary tests against the real user/cmd/userservice binary), the new integration/lobbynotification/ package (4 scenario tests covering 8 of 11 lobby notification:intents producer types), the updated LOBBY_PENDING_REGISTRATION_TTL_HOURS clarification at lobby/README.md:1130-1136, the /healthz//readyz realignment in lobby/api/internal-openapi.yaml:49,66, and the new TestPublicSpecDeclaresAllRegisteredRoutes / TestInternalSpecDeclaresAllRegisteredRoutes route-table contract tests in lobby/contract_openapi_test.go. ARCHITECTURE.md §7 and §10 were spot-checked and required no edits.

Goal:

  • close the loop across service tests, boundary tests, and documentation

Tasks:

  • Verify all README.md claims against the implemented behavior.
  • Add integration tests in the integration/ module for:
    • Lobby → User Service eligibility check boundary
    • Lobby → Notification Service intent publication for all seven types
  • Align lobby/api/public-openapi.yaml and internal-openapi.yaml with the final implemented routes.
  • Run go test ./... -race -cover across the lobby module.
  • Verify ARCHITECTURE.md still matches the final implementation.

Exit criteria:

  • go test ./... -race passes for the lobby module and the integration module.
  • no contradictions between lobby/README.md, ARCHITECTURE.md, and implemented behavior.

Stage 21. User Service: user_name + display_name refactor

Status: implemented — see user/docs/stage21-user-name-display-name.md, lobby/internal/domain/racename/, and the Gateway boundary rename across pkg/schema/fbs/user.fbs, pkg/transcoder/user.go, pkg/model/user, and the integration + gateway contract tests.

Cross-service stage owned by galaxy/user. Must land before Stage 17A so the eligibility snapshot carries max_registered_race_names. Can run in parallel with Stages 09R and 10 once Stage 21.121.4 are complete.

Tasks:

  • 21.1. Add UserName and DisplayName value types in user/internal/domain/common/types.go (or an adjacent file). UserName matches player-<suffix> with suffix 8 characters from a confusable-free alphanumeric alphabet; DisplayName delegates validation to pkg/util/string.go:ValidateTypeName and tolerates empty strings.
  • 21.2. Replace RaceName with UserName and add DisplayName on UserAccount in user/internal/domain/account/model.go. Delete RaceNameReservation and RaceNameCanonicalKey types.
  • 21.3. Rename IDGenerator.NewInitialRaceNameNewUserName. Update its implementation to use an 8-character confusable-free alphanumeric suffix (AppendRandomSuffix pattern in pkg/util/string.go is a reference but will need a new alphabet). Keep collision retries by store response; increase the ensureCreateRetryLimit from 8 to 10.
  • 21.4. Delete user/internal/ports/race_name_policy.go and its adapters. Move confusable-pair policy (including fixtures and tests) to lobby/internal/domain/racename/ — this feeds Stage 09R.
  • 21.5. Update authdirectory.Ensurer: the ensure-by-email path creates UserName via the renamed generator; DisplayName remains empty; no race name reservation is created.
  • 21.6. Update selfservice.ProfileUpdater: accept only display_name, validate via ValidateTypeName. user_name is immutable and returned read-only in the account view.
  • 21.7. Extend lobbyeligibility.SnapshotReader to materialize max_registered_race_names in EffectiveLimits (free=1, paid_monthly=2, paid_yearly=6, paid_lifetime=0 marker) and to respect any user-specific LimitCodeMaxRegisteredRaceNames override.
  • 21.8. Extend adminusers list/search: exact + prefix filters by user_name and display_name; update listing ordering if needed.
  • 21.9. Update user/internal/api/internalhttp/ handlers, user/openapi.yaml, and contract tests (openapi_contract_test.go, runtime_contract_test.go).
  • 21.10. Update user/README.md and user/docs/ to reflect user_name/display_name. Remove every reference to race_name in user docs.
  • 21.11. Update integration/ cross-service tests (gateway scenarios, auth/session wiring, lobby eligibility consumption).

Exit criteria:

  • go test ./... -race passes for the user module and integration module.
  • ensure-by-email returns only user_id, populating user_name and leaving display_name empty.
  • update-my-profile modifies only display_name.
  • eligibility snapshot JSON carries max_registered_race_names.
  • no source file in galaxy/user references race_name or RaceNameReservation after the stage.

Stage 22 — User Service: permanent_block + DeleteUser

Cross-service stage owned by galaxy/user. Required before Stage 23.

Tasks:

  • 22.1. Add policy.SanctionCodePermanentBlock to the supported catalog; extend lobby-relevant filter so that the sanction always surfaces in the eligibility snapshot; update deriveEligibilityMarkers so that an active permanent_block collapses every can_* marker to false.
  • 22.2. Add policy.LimitCodeMaxRegisteredRaceNames to the supported catalog so admin overrides are possible.
  • 22.3. Add service/accountdeletion/ (new) and POST /api/v1/internal/users/{user_id}/delete endpoint. Soft-delete: mark UserAccount.DeletedAt; reject all subsequent auth, self-service, admin-read, and lobby-eligibility operations with subject_not_found for external callers; emit user.lifecycle.deleted event.
  • 22.4. Add ports.UserLifecyclePublisher and Redis stream user:lifecycle_events. Emit:
    • user.lifecycle.permanent_blocked on application of SanctionCodePermanentBlock via adminusers path;
    • user.lifecycle.deleted on successful DeleteUser. Fields: user_id, occurred_at_ms, actor, reason_code.
  • 22.5. Update user/openapi.yaml, handlers, and contract tests.

Exit criteria:

  • permanent_block surfaces in the eligibility snapshot and drives all can_* to false.
  • DeleteUser is idempotent per user_id; a second call after soft-delete returns subject_not_found.
  • user:lifecycle_events receives exactly one event per state transition.

Stage 23. Lobby: user:lifecycle_events consumer + cascade release

Status: implemented — see lobby/docs/stage23-user-lifecycle-consumer-decisions.md, the new lobby/internal/ports/userlifecyclestream.go, lobby/internal/adapters/userlifecycle/consumer.go (with the in-memory lobby/internal/adapters/userlifecyclestub/consumer.go for tests), lobby/internal/worker/userlifecycle/worker.go, the InviteStore.GetByInviter and GameStore.GetByOwner extensions across ports + redisstate + stubs, the new game.TriggerExternalBlock plus */in-flight → cancelled transitions in lobby/internal/domain/game/status.go, the synchronous UserService.GetEligibility guard added to lobby/internal/service/redeeminvite/service.go, the LOBBY_USER_LIFECYCLE_* configuration knobs in lobby/internal/config/{config,env}.go, the worker + consumer wiring in lobby/internal/app/{wiring,runtime}.go, the new lobby.membership.blocked notification type across pkg/notificationintent/{intent,payloads,intent_test}.go, pkg/schema/fbs/notification.fbs (with regenerated pkg/schema/fbs/notification/LobbyMembershipBlockedEvent.go), pkg/transcoder/notification.go, notification/api/intents-asyncapi.yaml, notification/internal/{api/intentstream/contract,service/publishpush/encoder}.go, the contract-test fixtures in notification/{contract_asyncapi,producer_integration_contract,push_payload_contract,mail_template_contract}_test.go, the new mail templates under mail/templates/lobby.membership.blocked/en/, and the README updates in lobby/README.md (Notification Contracts, Cascade release, Status transition table, Redis Logical Model, Observability) plus notification/README.md and gateway/README.md.

Tasks:

  • Add internal/ports/userlifecyclestream.go: UserLifecycleConsumer abstraction with Run(ctx) error and OnEvent(handler).
  • Add internal/adapters/userlifecycle/consumer.go: Redis Streams consumer; offset persisted at lobby:stream_offsets:user_lifecycle.
  • Add internal/worker/userlifecycle/worker.go:
    • on user.lifecycle.permanent_blocked or user.lifecycle.deleted:
      • RND.ReleaseAllByUser(user_id);
      • mark every active Membership for the user as blocked with trigger external_block;
      • cancel every submitted application and every created invite owned or addressed to the user;
      • publish lobby.membership.blocked intents to private game owners where applicable (reuse existing notification type or introduce lobby.user.permanent_blocked — freeze choice in Stage 03R).
  • Wire worker startup in app/runtime.go.
  • Tests (miniredis + fake stream): full cascade, replay idempotency, partial failure retry.

Exit criteria:

  • A permanent_blocked event releases every RND entry for the user and settles every lobby artefact atomically (per-entity operations OK; overall consistency is eventual but within one event pass).
  • Replaying the stream does not double-release.
  • Offset advances only after full event handling.

Stage 24. Notification intent catalog additions

Status: implemented — see lobby/docs/stage24-race-name-intents-decisions.md, the new constants and constructors in pkg/notificationintent/{intent,payloads}.go (with corresponding test rows in intent_test.go), the AsyncAPI extension in notification/api/intents-asyncapi.yaml and contract-test fixture updates across notification/{contract_asyncapi,producer_integration_contract,mail_template_contract,push_payload_contract}_test.go, the new LobbyRaceNameRegistrationEligibleEvent and LobbyRaceNameRegisteredEvent tables in pkg/schema/fbs/notification.fbs with regenerated Go bindings, the matching transcoder helpers in pkg/transcoder/notification.go, the new switch arms in notification/internal/service/publishpush/encoder.go, mail templates under mail/templates/lobby.race_name.{registration_eligible,registered,registration_denied}/en/, the gateway README push vocabulary update, the new lobby/internal/adapters/racenameintents/ adapter wired through lobby/internal/app/wiring.go (replacing capabilityevaluation.NoopRaceNameIntents{}), and the notification/internal/api/intentstream/contract.go aliases.

Tasks:

  • Extend pkg/notificationintent/intent.go vocabulary with NotificationTypeLobbyRaceNameRegistrationEligible, NotificationTypeLobbyRaceNameRegistered, and (optional) NotificationTypeLobbyRaceNameRegistrationDenied.
  • Update ExpectedProducer, SupportsAudience, SupportsChannel, and validatePayloadObject accordingly.
  • Extend pkg/notificationintent/payloads.go with:
    • LobbyRaceNameRegistrationEligiblePayload{GameID, GameName, RaceName, EligibleUntilMs}
    • LobbyRaceNameRegisteredPayload{RaceName}
    • LobbyRaceNameRegistrationDeniedPayload{GameID, GameName, RaceName, Reason} and matching NewXxxIntent constructors.
  • Update notification/api/intents-asyncapi.yaml and related contract tests.

Exit criteria:

  • pkg/notificationintent tests cover all new types end-to-end (build, encode, decode, validate).
  • AsyncAPI contract stays valid.
  • Stage 15A and Stage 17A can publish intents via the new constructors.

Execution Order

  1. Stage 21 — User Service refactor.
  2. Stage 22 — User Service permanent_block + DeleteUser.
  3. Stage 01R, 03R — documentation alignment.
  4. Stage 09R — RND port + Redis adapter.
  5. Stage 10.
  6. Stage 11, 12 — updated race name flows.
  7. Stage 13, 14, 14A.
  8. Stage 15, 15A.
  9. Stage 16.
  10. Stage 17.
  11. Stage 24 — notification catalog (prerequisite for 15A/17A intents).
  12. Stage 17A, 17B, 17C.
  13. Stage 18.
  14. Stage 23 — user lifecycle consumer.
  15. Stage 19.
  16. Stage 20.

Final Acceptance Criteria

The implementation is complete only when all of the following hold:

  • all status transition invariants are enforced; no unsupported transition can be triggered by any API call
  • enrollment automation handles all three auto-transition paths correctly and idempotently
  • Race Name Directory (two-tier) enforces platform-wide uniqueness across registered names, active reservations, and pending registrations; canonical key + confusable-pair policy applies to every path
  • per-game reservations respect the "same user may hold the same name across multiple active games" invariant
  • initial planets / population are captured once per member per game from the first post-start runtime_snapshot_update
  • capability evaluation at game_finished resolves every active reservation (pending if capable, released otherwise) atomically relative to the event offset advance
  • race name registration respects the tariff snapshot (max_registered_race_names), the 30-day pending window, and idempotent retry
  • pending-registration expiration worker releases every expired entry without double-releasing on restart
  • user:lifecycle_events consumer cascades permanent_blocked and deleted to full RND release, membership blocking, and application/invite cancellation
  • application and invite flows produce the correct notifications at each step
  • game start flow handles runtime failure, GM unavailability, and metadata persistence failure correctly
  • GM runtime snapshot updates are applied durably from the stream and feed the per-game stats aggregate
  • game finish triggered by GM stream transitions the game correctly and drives capability evaluation before offset advance
  • denormalized runtime snapshot is always returned from the game record without a round-trip to Game Master
  • private game visibility rules are enforced at every list and read endpoint
  • all configuration can be supplied via environment variables with documented defaults
  • User Service no longer owns a race_name concept; user_name and display_name fully replace it, and ensure-by-email uses the renamed generator
  • go test ./... -race passes for the lobby module, the user module, the pkg/notificationintent module, and the integration module