# 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 ```text 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::`. - 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:`, `lobby:race_names:reservations::`, `lobby:race_names:user_registered:`, `lobby:race_names:user_reservations:`, `lobby:race_names:pending_index`, `lobby:race_names:canonical_lookup:`, `lobby:game_turn_stats::`). - 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::`; 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.1–21.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-` 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.NewInitialRaceName` → `NewUserName`. 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 ## Note: Runtime Manager Envelope Evolution Subsequent changes to the `runtime:start_jobs` and `runtime:stop_jobs` envelopes — specifically the addition of `image_ref` to the start envelope and the addition of the `reason` enum to the stop envelope — are owned by the Runtime Manager implementation plan, not by this document. See [`../rtmanager/PLAN.md`](../rtmanager/PLAN.md) §«Stage 06. Lobby publisher refactor». No new stages are added here for that work.