61 KiB
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 inGame Masterbeyond 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_namevalues.User Serviceownsuser_name(immutable handle) anddisplay_name(free text), neverrace_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 anyregistered, activereservation, orpending_registrationby 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 inUser Service. - Post-game capability is evaluated by
Lobbyatgame_finished:capable = max_planets > initial_planets AND max_population > initial_population. Capable reservations are moved topending_registrationwitheligible_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 whenLobbyconsumes apermanent_blockedordeletedevent fromuser: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 Masterfor 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 Servicepossible. 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.mdwith all decisions made during planning
Tasks:
- Replace the Lobby status model block: remove
enrollment_closed, addstart_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 Lobbyruntime snapshot stream to the fixed asynchronous interactions list.
Exit criteria:
ARCHITECTURE.mdaccurately 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_namefrom owned state; adduser_name,display_name,permanent_blocksanction,DeleteUserendpoint,max_registered_race_namesin eligibility snapshot. - Update §7 and §8:
runtime_snapshot_updateingm:lobby_eventscarriesplayer_turn_stats(planets,population,ships_builtper user); Lobby maintains per-game/per-user stats aggregate. - Update §11 Billing Service: tariff changes affect only new registrations.
- Add
User Service → Game Lobbyto «Fixed asynchronous interactions» asuser:lifecycle_events(permanent_blocked, deleted).
Exit criteria:
ARCHITECTURE.mdmatches the locked RND design; no contradictions withlobby/README.mdoruser/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.mdsections 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, andMembershipinREADME.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.revokedandlobby.invite.declinedproduce no notification in v1.
Exit criteria:
lobby/README.mdsections Application Lifecycle, Invite Lifecycle, Membership Model, and Race Name Directory contain no unresolved questions.- Notification intent shapes in
README.mdare consistent with the frozen catalog innotification/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_keyalongsiderace_name. - Update §Runtime Snapshot: add
player_turn_stats(initial + currentplanets,population,ships_builtper user). Lobby caches aggregates underlobby:game_turn_stats:<game_id>:<user_id>. - Add §Race Name Registration flow:
- capability evaluation at
game_finished; pending_registrationwindow = 30 days;lobby.race_name.registermessage type with tariff + capability checks;- fast-path self-service read
lobby.race_names.list.
- capability evaluation at
- 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_INTERVALdefault1h,LOBBY_PENDING_REGISTRATION_TTL_HOURSdefault720,LOBBY_USER_LIFECYCLE_STREAMdefaultuser:lifecycle_events,LOBBY_RACE_NAME_DIRECTORY_BACKENDdefaultredis. - 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.mddescribes 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.yamlcovering all message types from the message type catalog inREADME.md. - Add
lobby/api/internal-openapi.yamlcovering 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.mdNotification Contracts to the correct constructor ingalaxy/notificationintent. - Confirm that
NewPublicLobbyApplicationSubmittedIntentis the only path forlobby.application.submittedin v1. - Confirm
lobby.invite.expiredis 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.moddependencies:redis/go-redis/v9withredisotel, thego.opentelemetry.io/otelv1.43 stack withotelhttp,testcontainers-gotogether withmodules/redis,alicebob/miniredis/v2, andstretchr/testify. The skeleton uses the Go standard librarynet/http; no web framework is added. This mirrors the dependency set used bymailandnotification. - Add
cmd/lobby/main.gowith signal handling and context propagation. - Add
internal/config/with env loading, validation, andDefaultConfig(). - Add
internal/app/runtime.go: Redis startup check, structured logger, telemetry provider, graceful shutdown, composed through the genericapp.Componentlifecycle ininternal/app/app.goand helpers ininternal/app/bootstrap.go. - Add
internal/api/publichttp/andinternal/api/internalhttp/routers withGET /healthzandGET /readyzonly. - Wire both HTTP listeners in
app/runtime.gothroughapp.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
/healthzon 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, constructorNew(...)that validates all required fields. - Add
internal/domain/game/status.go:Statustype, all status constants,AllowedTransitionsmap,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 usingminiredis; 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 withmodel.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 ./... -racepasses.
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:RaceNameDirectoryinterface (Reserve,Release,Check) withErrNameTakensentinel. - Add
internal/adapters/racenamestub/directory.go: in-memorysync.Mapimplementation. - Wire the stub in
internal/app/wiring.go. - Add unit tests for the stub covering reserve, release, check, and uniqueness invariant.
Exit criteria:
racenamestubtests pass.- all future service code refers to
ports.RaceNameDirectory; no direct reference toracenamestuboutside 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.gounder the new interface (see Stage 03R) with sentinelsErrNameTaken,ErrPendingExpired,ErrPendingMissing,ErrInvalidName,ErrQuotaExceeded. - Add
lobby/internal/domain/racename/policy.go: canonical key generation (lowercase + frozen confusable-pair rules ported fromuser/internal/ports/race_name_policy.go),ValidateTypeNameintegration frompkg/util. - Implement
lobby/internal/adapters/redisstate/racenamedir.goatop the Redis key layout in Stage 03R; tests useminiredis. - Rewrite
lobby/internal/adapters/racenamestub/directory.goagainst the new interface so unit tests that do not need Redis stay fast. - Wire adapter selection in
internal/app/wiring.goviaLOBBY_RACE_NAME_DIRECTORY_BACKEND(redisdefault,stubfor tests). - Port the User Service
RaceNameReservation/RaceNamePolicytests and their golden fixtures tolobby/internal/domain/racename/.
Exit criteria:
- Redis adapter and stub both pass the same behavioural test suite (interface-level table tests).
- Idempotent
Reserveby the same user under the same game returns nil. Checkexposes(taken, holder_user_id, kind)consistent with Redis state.MarkPendingRegistrationleaves the existing reservation accessible toListPendingRegistrationsand toExpirePendingRegistrations.ReleaseAllByUserclears 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 indraftstatus, store viaGameStore. - Add
internal/service/updategame/: allow edits ondraftand selected fields onenrollment_open; reject all other statuses. - Add
internal/service/openenrollment/:draft → enrollment_openwith admin/owner authorization check. - Add
internal/service/cancelgame/: cancel fromdraft,enrollment_open,ready_to_start,start_failed; reject fromstarting,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:UserServiceinterface (GetEligibility(ctx, userID) (Eligibility, error)). - Add
internal/adapters/userservice/client.go: HTTP client hittingGET /api/v1/internal/users/{user_id}/eligibility. - Add
internal/service/submitapplication/:- game type must be
publicand statusenrollment_open - call
UserService.GetEligibility; fail ifcan_join_game=false - call
RaceNameDirectory.Check(raceName, actorUserID); fail if name is taken by another user (returnsname_taken) or permanent-blocked - create
Application{status: submitted, canonical_key} - publish
lobby.application.submittedintent viagalaxy/notificationintent
- game type must be
- Add
internal/service/approveapplication/:- call
RaceNameDirectory.Reserve(gameID, userID, raceName); idempotent - create
Membership{status: active, canonical_key} - set application
status=approved - publish
lobby.membership.approvedintent - trigger gap window open if
approved_count == max_players
- call
- 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.rejectedintent
- call
- Wire routes.
- Add service tests with in-memory stores, stubbed
UserService, and stubRaceNameDirectory.
Exit criteria:
- all three application operations pass tests.
- eligibility denial surfaces as
eligibility_deniederror. - name conflict surfaces as
name_takenerror. - 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, statusenrollment_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.createdintent
- game type must be
- Add
internal/service/redeeminvite/:- invite status must be
created, game statusenrollment_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.redeemedintent to owner - trigger gap window open if
approved_count == max_players
- invite status must be
- Add
internal/service/declineinvite/: setstatus=declined; no notification. - Add
internal/service/revokeinvite/: setstatus=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.createdandlobby.invite.redeemedare 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(default30s) - on each tick, load all games in
enrollment_openstatus - for each game check:
- deadline:
now >= enrollment_ends_at && approved_count >= min_players - gap exhaustion: gap window is open and (
now >= gap_activated_at + start_gap_hoursorapproved_count >= max_players + start_gap_players)
- deadline:
- on transition to
ready_to_start:- atomically expire all
createdinvites for the game - publish
lobby.invite.expiredintents (one per expired invite)
- atomically expire all
- periodic ticker with
- 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_countreachesmax_players, recordgap_activated_atin Redis. - Add tests using a fake clock; cover all three auto-transition paths and the
boundary condition where the deadline fires but
min_playersis 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:RuntimeManagerinterface (PublishStartJob(ctx, gameID string) error,PublishStopJob(ctx, gameID string) error). - Add Redis stream adapter for
RuntimeManager(write-only; publishes toruntime:start_jobs). - Add
internal/ports/gmclient.go:GMClientinterface (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
- validate
- Add
internal/worker/runtimejobresult/consumer.go:- consume
runtime:job_resultsstream - on failure result: set status →
start_failed - on success result:
- persist
runtime_bindingmetadata on game record - call
GMClient.RegisterGamesynchronously - on GM success: set status →
running; setstarted_at - on GM failure/timeout: set status →
paused; publishlobby.runtime_paused_after_startintent
- persist
- on metadata persistence failure before GM call: publish stop job to
RuntimeManager; set status →start_failed
- consume
- Add
internal/service/retrystartgame/:start_failed → ready_to_start. - Wire consumer in
app/runtime.go. - Add tests with stubbed
RuntimeManagerandGMClient; cover all four outcome paths.
Exit criteria:
- success path: game reaches
runningafter 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_populationat the firstruntime_snapshot_updateafterstarting → running
Tasks:
- Add
internal/ports/gameturnstatsstore.go:GameTurnStatsStorewithSaveInitial(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.gokeyed underlobby:game_turn_stats:<game_id>:<user_id>; tests withminiredis. - Extend the GM event DTO in
internal/worker/gmevents/to decodeplayer_turn_stats. - In the consumer, invoke
SaveInitialonce per game (no-op on subsequent calls to preserve the first observation) andUpdateMaxon everyruntime_snapshot_update.
Exit criteria:
- Initial stats do not change on subsequent snapshots.
UpdateMaxuses 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_eventsstream - on
runtime_snapshot_updateevent:- call
GameStore.UpdateRuntimeSnapshot(turn, status, health) - call
GameTurnStatsStore.SaveInitial(first call only) andUpdateMaxusingplayer_turn_stats(Stage 14A)
- call
- on
game_finishedevent:- apply final snapshot; transition game to
finished; setfinished_at - hand off to Stage 15A capability evaluator before acknowledging offset
- apply final snapshot; transition game to
- advance stream offset only after successful processing
- consume
- Add tests using
minirediswith fake events; cover snapshot update, game_finished, and replay idempotency.
Exit criteria:
- snapshot updates are applied without changing game status.
game_finishedtransitions game tofinished, setsfinished_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_registrationor 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)+ intentlobby.race_name.registration_eligible - not capable ⇒
RND.ReleaseReservation(gameID, userID, raceName)+ (optional) intentlobby.race_name.registration_denied
- for
removed/blockedmemberships with outstanding reservations: release immediately - delete
GameTurnStatsStoreaggregate for the game after evaluation
- input: finished game id, final stats aggregate from
- Hook the evaluator into
gmeventsconsumer aftergame_finishedprocessing. - Tests for capable / not-capable / mixed rosters, and for idempotency on replay.
Exit criteria:
- every
activemembership of a finished game produces exactly one RND side effect (mark pending or release). - replayed
game_finishedevents 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.Pingor equivalent) - on GM reachable: transition to
running - on GM unreachable: return
service_unavailable; game remainspaused
- 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
runningwhen 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 sogame_finishedevaluation decides its fate (Stage 15A)
- before game start: drop membership; call
- Add
internal/service/blockmember/:- set membership
status=blocked - race name reservation is preserved; Stage 15A releases it at
game_finished
- set membership
- 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_finishedand 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_registrationreservation into a permanent registered race name
Tasks:
- Add
internal/service/registerracename/:- input:
{race_name, source_game_id}; acting user fromX-User-ID - preconditions:
- canonical-key
pending_registrationexists for(source_game_id, user_id)witheligible_until > now UserService.GetEligibilitysnapshot: activemax_registered_race_names> current registered count (0denotes unlimited);can_update_profileis not required- no
permanent_blockon the user
- canonical-key
- commit:
RND.Register(source_game_id, user_id, race_name); emit intentlobby.race_name.registered
- input:
- Wire route
POST /api/v1/lobby/race-names/registeron 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_registrationwhoseeligible_untilhas passed
Tasks:
- Add
internal/worker/pendingregistration/worker.go:- ticker with
LOBBY_RACE_NAME_EXPIRATION_INTERVAL(default1h) - call
RND.ExpirePendingRegistrations(now) - for each expired entry: release the reservation and increment
lobby.pending_registration.expirations - no notification (informational only)
- ticker with
- Tests using a fake clock and
miniredis: boundary exactly ateligible_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[]} pendingcarrieseligible_until_msandsource_game_idreservationscarriesgame_idand currentgame_status
- returns
- Wire
GET /api/v1/lobby/my/race-names; updatepublic-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_reservationsindexes).
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,finishedonly - authenticated user also sees their private game memberships
- public list:
- Add
internal/service/listmemberships/:- admin, owner, or active member may list memberships of a game
- Wire
lobby.my_games.list,lobby.my_applications.list, andlobby.my_invites.listroutes. - 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.mdObservability section using the OpenTelemetry SDK. - Add structured log fields for all key operations (transitions, notification publishes, enrollment automation triggers, stream consumer events).
- Propagate
request_idandtrace_idthrough 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.mdclaims against the implemented behavior. - Add integration tests in the
integration/module for:Lobby → User Serviceeligibility check boundaryLobby → Notification Serviceintent publication for all seven types
- Align
lobby/api/public-openapi.yamlandinternal-openapi.yamlwith the final implemented routes. - Run
go test ./... -race -coveracross the lobby module. - Verify
ARCHITECTURE.mdstill matches the final implementation.
Exit criteria:
go test ./... -racepasses 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
UserNameandDisplayNamevalue types inuser/internal/domain/common/types.go(or an adjacent file).UserNamematchesplayer-<suffix>with suffix 8 characters from a confusable-free alphanumeric alphabet;DisplayNamedelegates validation topkg/util/string.go:ValidateTypeNameand tolerates empty strings. - 21.2. Replace
RaceNamewithUserNameand addDisplayNameonUserAccountinuser/internal/domain/account/model.go. DeleteRaceNameReservationandRaceNameCanonicalKeytypes. - 21.3. Rename
IDGenerator.NewInitialRaceName→NewUserName. Update its implementation to use an 8-character confusable-free alphanumeric suffix (AppendRandomSuffixpattern inpkg/util/string.gois a reference but will need a new alphabet). Keep collision retries by store response; increase theensureCreateRetryLimitfrom8to10. - 21.4. Delete
user/internal/ports/race_name_policy.goand its adapters. Move confusable-pair policy (including fixtures and tests) tolobby/internal/domain/racename/— this feeds Stage 09R. - 21.5. Update
authdirectory.Ensurer: the ensure-by-email path createsUserNamevia the renamed generator;DisplayNameremains empty; no race name reservation is created. - 21.6. Update
selfservice.ProfileUpdater: accept onlydisplay_name, validate viaValidateTypeName.user_nameis immutable and returned read-only in the account view. - 21.7. Extend
lobbyeligibility.SnapshotReaderto materializemax_registered_race_namesinEffectiveLimits(free=1, paid_monthly=2, paid_yearly=6, paid_lifetime=0 marker) and to respect any user-specificLimitCodeMaxRegisteredRaceNamesoverride. - 21.8. Extend
adminuserslist/search: exact + prefix filters byuser_nameanddisplay_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.mdanduser/docs/to reflectuser_name/display_name. Remove every reference torace_namein user docs. - 21.11. Update
integration/cross-service tests (gateway scenarios, auth/session wiring, lobby eligibility consumption).
Exit criteria:
go test ./... -racepasses for the user module and integration module.- ensure-by-email returns only
user_id, populatinguser_nameand leavingdisplay_nameempty. - update-my-profile modifies only
display_name. - eligibility snapshot JSON carries
max_registered_race_names. - no source file in
galaxy/userreferencesrace_nameorRaceNameReservationafter 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.SanctionCodePermanentBlockto the supported catalog; extend lobby-relevant filter so that the sanction always surfaces in the eligibility snapshot; updatederiveEligibilityMarkersso that an activepermanent_blockcollapses everycan_*marker tofalse. - 22.2. Add
policy.LimitCodeMaxRegisteredRaceNamesto the supported catalog so admin overrides are possible. - 22.3. Add
service/accountdeletion/(new) andPOST /api/v1/internal/users/{user_id}/deleteendpoint. Soft-delete: markUserAccount.DeletedAt; reject all subsequent auth, self-service, admin-read, and lobby-eligibility operations withsubject_not_foundfor external callers; emituser.lifecycle.deletedevent. - 22.4. Add
ports.UserLifecyclePublisherand Redis streamuser:lifecycle_events. Emit:user.lifecycle.permanent_blockedon application ofSanctionCodePermanentBlockviaadminuserspath;user.lifecycle.deletedon successfulDeleteUser. 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 returnssubject_not_found. user:lifecycle_eventsreceives 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:UserLifecycleConsumerabstraction withRun(ctx) errorandOnEvent(handler). - Add
internal/adapters/userlifecycle/consumer.go: Redis Streams consumer; offset persisted atlobby:stream_offsets:user_lifecycle. - Add
internal/worker/userlifecycle/worker.go:- on
user.lifecycle.permanent_blockedoruser.lifecycle.deleted:RND.ReleaseAllByUser(user_id);- mark every active
Membershipfor the user asblockedwith triggerexternal_block; - cancel every
submittedapplication and everycreatedinvite owned or addressed to the user; - publish
lobby.membership.blockedintents to private game owners where applicable (reuse existing notification type or introducelobby.user.permanent_blocked— freeze choice in Stage 03R).
- on
- Wire worker startup in
app/runtime.go. - Tests (
miniredis+ fake stream): full cascade, replay idempotency, partial failure retry.
Exit criteria:
- A
permanent_blockedevent 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.govocabulary withNotificationTypeLobbyRaceNameRegistrationEligible,NotificationTypeLobbyRaceNameRegistered, and (optional)NotificationTypeLobbyRaceNameRegistrationDenied. - Update
ExpectedProducer,SupportsAudience,SupportsChannel, andvalidatePayloadObjectaccordingly. - Extend
pkg/notificationintent/payloads.gowith:LobbyRaceNameRegistrationEligiblePayload{GameID, GameName, RaceName, EligibleUntilMs}LobbyRaceNameRegisteredPayload{RaceName}LobbyRaceNameRegistrationDeniedPayload{GameID, GameName, RaceName, Reason}and matchingNewXxxIntentconstructors.
- Update
notification/api/intents-asyncapi.yamland related contract tests.
Exit criteria:
pkg/notificationintenttests 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
Stage 21— User Service refactor.Stage 22— User Servicepermanent_block+DeleteUser.Stage 01R, 03R— documentation alignment.Stage 09R— RND port + Redis adapter.Stage 10.Stage 11, 12— updated race name flows.Stage 13,14,14A.Stage 15,15A.Stage 16.Stage 17.Stage 24— notification catalog (prerequisite for 15A/17A intents).- Stage
17A,17B,17C. Stage 18.Stage 23— user lifecycle consumer.Stage 19.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/populationare captured once per member per game from the first post-startruntime_snapshot_update - capability evaluation at
game_finishedresolves 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_eventsconsumer cascadespermanent_blockedanddeletedto 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_nameconcept;user_nameanddisplay_namefully replace it, and ensure-by-email uses the renamed generator go test ./... -racepasses for the lobby module, the user module, thepkg/notificationintentmodule, 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 §«Stage 06. Lobby publisher
refactor». No new stages are added here for that work.