Files
galaxy-game/lobby/PLAN.md
T
2026-04-28 20:39:18 +02:00

1453 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<game_id>:<user_id>`.
- Add §Race Name Registration flow:
- capability evaluation at `game_finished`;
- `pending_registration` window = 30 days;
- `lobby.race_name.register` message type with tariff + capability checks;
- fast-path self-service read `lobby.race_names.list`.
- Extend §Notification Contracts with
`lobby.race_name.registration_eligible`, `lobby.race_name.registered`,
`lobby.race_name.registration_denied` (optional).
- Extend §Error Model with `race_name_registration_quota_exceeded`,
`race_name_pending_window_expired`, `race_name_capability_not_met`,
`race_name_permanent_blocked`.
- Extend §Configuration: `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` default `1h`,
`LOBBY_PENDING_REGISTRATION_TTL_HOURS` default `720`,
`LOBBY_USER_LIFECYCLE_STREAM` default `user:lifecycle_events`,
`LOBBY_RACE_NAME_DIRECTORY_BACKEND` default `redis`.
- Extend §Redis Logical Model with RND keys
(`lobby:race_names:registered:<canonical_key>`,
`lobby:race_names:reservations:<game_id>:<canonical_key>`,
`lobby:race_names:user_registered:<user_id>`,
`lobby:race_names:user_reservations:<user_id>`,
`lobby:race_names:pending_index`,
`lobby:race_names:canonical_lookup:<canonical_key>`,
`lobby:game_turn_stats:<game_id>:<user_id>`).
- Extend §Observability with `lobby.race_name.outcomes`,
`lobby.pending_registration.expirations`,
`lobby.user_lifecycle.cascade_releases`.
Exit criteria:
- `lobby/README.md` describes the full RND surface; downstream code stages can
reference it without further ambiguity.
## ~~Stage 04.~~ Define OpenAPI Contracts
Status: implemented — see `lobby/api/public-openapi.yaml`,
`lobby/api/internal-openapi.yaml`, and `lobby/docs/stage04-openapi-decisions.md`.
Goal:
- produce stable REST contract files before wiring HTTP handlers
Tasks:
- Add `lobby/api/public-openapi.yaml` covering all message types from the
message type catalog in `README.md`.
- Add `lobby/api/internal-openapi.yaml` covering GM registration and admin
endpoints.
- Freeze request and response shapes for all routes.
- Document authorization expectations per route (admin, owner, any member, etc.).
Exit criteria:
- both OpenAPI files are syntactically valid and cover every route in the message
type catalog.
## ~~Stage 05.~~ Freeze Notification Intent Publishing Rules
Status: implemented — see `lobby/README.md` Notification Contracts section and
the existing `galaxy/notificationintent` module.
Goal:
- confirm all notification triggers before service and worker code touches
`galaxy/notificationintent`
Tasks:
- Map every trigger in `README.md` Notification Contracts to the correct
constructor in `galaxy/notificationintent`.
- Confirm that `NewPublicLobbyApplicationSubmittedIntent` is the only path for
`lobby.application.submitted` in v1.
- Confirm `lobby.invite.expired` is published per-invite (not batched into one
intent).
Exit criteria:
- every notification trigger has an identified constructor in
`galaxy/notificationintent`; no new constructor is needed for v1.
## ~~Stage 06.~~ Module Skeleton
Status: implemented — see `lobby/cmd/lobby`,
`lobby/internal/{config,logging,telemetry,app,api/publichttp,api/internalhttp}`,
and `lobby/docs/stage06-skeleton-decisions.md`.
Goal:
- create the runnable service process with no business logic
Tasks:
- Add `go.mod` dependencies: `redis/go-redis/v9` with `redisotel`,
the `go.opentelemetry.io/otel` v1.43 stack with `otelhttp`,
`testcontainers-go` together with `modules/redis`,
`alicebob/miniredis/v2`, and `stretchr/testify`. The skeleton uses the
Go standard library `net/http`; no web framework is added. This mirrors
the dependency set used by `mail` and `notification`.
- Add `cmd/lobby/main.go` with signal handling and context propagation.
- Add `internal/config/` with env loading, validation, and `DefaultConfig()`.
- Add `internal/app/runtime.go`: Redis startup check, structured logger,
telemetry provider, graceful shutdown, composed through the generic
`app.Component` lifecycle in `internal/app/app.go` and helpers in
`internal/app/bootstrap.go`.
- Add `internal/api/publichttp/` and `internal/api/internalhttp/` routers
with `GET /healthz` and `GET /readyz` only.
- Wire both HTTP listeners in `app/runtime.go` through `app.New(...)`.
Exit criteria:
- `go build ./...` succeeds with no errors.
- `go test ./...` passes (no tests yet beyond smoke).
- process starts with a valid Redis address and serves `/healthz` on both ports.
- process exits cleanly on `SIGTERM`.
## ~~Stage 07.~~ Game Domain Model and Redis Store
Status: implemented — see `lobby/internal/domain/{common,game}`,
`lobby/internal/ports/gamestore.go`, `lobby/internal/adapters/redisstate/`,
and `lobby/docs/stage07-game-store-decisions.md`.
Goal:
- implement the game entity with status enforcement and Redis persistence
Tasks:
- Add `internal/domain/game/model.go`: all game fields, value types, constructor
`New(...)` that validates all required fields.
- Add `internal/domain/game/status.go`: `Status` type, all status constants,
`AllowedTransitions` map, `Transition(from, to, trigger)` function that returns
an error for invalid transitions.
- Add `internal/domain/game/errors.go`: sentinel and typed errors
(`ErrNotFound`, `ErrConflict`, `ErrInvalidTransition`).
- Add `internal/ports/gamestore.go`: port interface
(`Get`, `GetByStatus`, `Save`, `UpdateStatus`, `UpdateRuntimeSnapshot`).
- Add `internal/adapters/redisstate/gamestore.go`: Redis implementation using
JSON serialization.
- Add `internal/adapters/redisstate/gamestore_test.go`: tests using
`miniredis`; cover create, get, update, status transition, snapshot update.
Exit criteria:
- all game store tests pass with `go test ./... -race`.
- invalid status transitions return an error at the domain level without touching
the store.
## ~~Stage 08.~~ Application, Invite, and Membership Stores
Status: implemented — see
`lobby/internal/domain/{application,invite,membership}`,
`lobby/internal/ports/{applicationstore,invitestore,membershipstore}.go`,
`lobby/internal/adapters/redisstate/{codecs_application,codecs_invite,codecs_membership,applicationstore,invitestore,membershipstore}.go`,
and `lobby/docs/stage08-store-decisions.md`.
Goal:
- add Redis-backed persistence for the three participant entities
Tasks:
- Add domain packages: `internal/domain/application/`, `internal/domain/invite/`,
`internal/domain/membership/` each with `model.go`, `status.go`, `errors.go`.
- Add port interfaces: `internal/ports/applicationstore.go`,
`internal/ports/invitestore.go`, `internal/ports/membershipstore.go`.
- Add Redis adapters for each entity under `internal/adapters/redisstate/`.
- Add tests for each adapter using `miniredis`.
- Enforce single active application per user per game at the store level.
Exit criteria:
- all three entity types persist, load, and list correctly.
- `go test ./... -race` passes.
## ~~Stage 09.~~ Race Name Directory Port and Stub
Status: implemented — see `lobby/internal/ports/racenamedir.go`,
`lobby/internal/adapters/racenamestub/`, `lobby/internal/app/wiring.go`,
and `lobby/docs/stage09-racenamedir-decisions.md`.
Goal:
- wire the Race Name Directory abstraction from the start so no code ever
imports a concrete implementation directly
Tasks:
- Add `internal/ports/racenamedir.go`: `RaceNameDirectory` interface
(`Reserve`, `Release`, `Check`) with `ErrNameTaken` sentinel.
- Add `internal/adapters/racenamestub/directory.go`: in-memory `sync.Map`
implementation.
- Wire the stub in `internal/app/wiring.go`.
- Add unit tests for the stub covering reserve, release, check, and uniqueness
invariant.
Exit criteria:
- `racenamestub` tests pass.
- all future service code refers to `ports.RaceNameDirectory`; no direct
reference to `racenamestub` outside the wiring layer.
## ~~Stage 09R.~~ Race Name Directory: two-tier model and Redis adapter
Status: implemented — see `lobby/docs/stage09R-racenamedir-decisions.md`,
`lobby/internal/ports/racenamedir.go`,
`lobby/internal/adapters/redisstate/racenamedir.go` (with
`racenamedir_lua.go` and `codecs_racename.go`),
`lobby/internal/adapters/racenamestub/directory.go`,
`lobby/internal/ports/racenamedirtest/suite.go`, and the
`RaceNameDirectoryConfig` group wired through
`internal/config/{config,env,validation}.go` and `internal/app/wiring.go`.
Replaces Stage 09's port and stub with the two-tier directory. Depends on
Stage 21 so the confusable-pair policy can be lifted out of `User Service`
without churn.
Tasks:
- Rewrite `lobby/internal/ports/racenamedir.go` under the new interface (see
Stage 03R) with sentinels `ErrNameTaken`, `ErrPendingExpired`,
`ErrPendingMissing`, `ErrInvalidName`, `ErrQuotaExceeded`.
- Add `lobby/internal/domain/racename/policy.go`: canonical key generation
(lowercase + frozen confusable-pair rules ported from
`user/internal/ports/race_name_policy.go`), `ValidateTypeName` integration
from `pkg/util`.
- Implement `lobby/internal/adapters/redisstate/racenamedir.go` atop the Redis
key layout in Stage 03R; tests use `miniredis`.
- Rewrite `lobby/internal/adapters/racenamestub/directory.go` against the new
interface so unit tests that do not need Redis stay fast.
- Wire adapter selection in `internal/app/wiring.go` via
`LOBBY_RACE_NAME_DIRECTORY_BACKEND` (`redis` default, `stub` for tests).
- Port the User Service `RaceNameReservation`/`RaceNamePolicy` tests and their
golden fixtures to `lobby/internal/domain/racename/`.
Exit criteria:
- Redis adapter and stub both pass the same behavioural test suite
(interface-level table tests).
- Idempotent `Reserve` by the same user under the same game returns nil.
- `Check` exposes `(taken, holder_user_id, kind)` consistent with Redis state.
- `MarkPendingRegistration` leaves the existing reservation accessible to
`ListPendingRegistrations` and to `ExpirePendingRegistrations`.
- `ReleaseAllByUser` clears every registered, reservation, and pending entry
for a user atomically (Lua or pipelined transaction).
- confusable-pair test fixtures from `user/internal/adapters/…` run in the new
package unchanged.
## ~~Stage 10.~~ Game Creation and Draft Management
Status: implemented — see `lobby/docs/stage10-game-lifecycle-decisions.md`,
`lobby/internal/service/{shared,creategame,updategame,openenrollment,cancelgame}`,
`lobby/internal/ports/idgenerator.go`, `lobby/internal/adapters/{idgen,gamestub}`,
and the extended `lobby/internal/api/{publichttp,internalhttp}` handlers.
Goal:
- implement the initial game lifecycle operations with no enrollment logic yet
Tasks:
- Add `internal/service/creategame/`: validate all game fields, create game in
`draft` status, store via `GameStore`.
- Add `internal/service/updategame/`: allow edits on `draft` and selected fields
on `enrollment_open`; reject all other statuses.
- Add `internal/service/openenrollment/`: `draft → enrollment_open` with
admin/owner authorization check.
- Add `internal/service/cancelgame/`: cancel from `draft`, `enrollment_open`,
`ready_to_start`, `start_failed`; reject from `starting`, `running`, `paused`.
- Wire all four service calls to routes on both HTTP ports.
- Add service-level tests (in-memory stores, no Redis).
Exit criteria:
- game creation, update, open-enrollment, and cancel all pass tests.
- unauthorized callers receive `forbidden`.
- invalid transition attempts return `conflict`.
## ~~Stage 11.~~ Application Flow (Public Games)
Status: implemented — see `lobby/docs/stage11-application-flow-decisions.md`,
`lobby/internal/service/{submitapplication,approveapplication,rejectapplication}`,
`lobby/internal/ports/{userservice,intentpublisher,gapactivationstore}.go`,
`lobby/internal/adapters/userservice/`,
`lobby/internal/adapters/redisstate/gapactivationstore.go`,
`lobby/internal/adapters/{applicationstub,membershipstub,gapactivationstub,userservicestub,intentpubstub}/`,
the `Membership.canonical_key` field across
`lobby/internal/domain/membership/model.go` and
`lobby/internal/adapters/redisstate/codecs_membership.go`, the
`NewApplicationID`/`NewMembershipID` extensions to
`lobby/internal/adapters/idgen/`, and the new application routes wired
through `lobby/internal/api/{publichttp,internalhttp}/applications.go`.
Goal:
- implement the full public-game application lifecycle
Tasks:
- Add `internal/ports/userservice.go`: `UserService` interface
(`GetEligibility(ctx, userID) (Eligibility, error)`).
- Add `internal/adapters/userservice/client.go`: HTTP client hitting
`GET /api/v1/internal/users/{user_id}/eligibility`.
- Add `internal/service/submitapplication/`:
- game type must be `public` and status `enrollment_open`
- call `UserService.GetEligibility`; fail if `can_join_game=false`
- call `RaceNameDirectory.Check(raceName, actorUserID)`; fail if name is taken
by another user (returns `name_taken`) or permanent-blocked
- create `Application{status: submitted, canonical_key}`
- publish `lobby.application.submitted` intent via `galaxy/notificationintent`
- Add `internal/service/approveapplication/`:
- call `RaceNameDirectory.Reserve(gameID, userID, raceName)`; idempotent
- create `Membership{status: active, canonical_key}`
- set application `status=approved`
- publish `lobby.membership.approved` intent
- trigger gap window open if `approved_count == max_players`
- Add `internal/service/rejectapplication/`:
- call `RaceNameDirectory.ReleaseReservation(gameID, userID, raceName)`
safe no-op when no reservation exists for the pair
- set application `status=rejected`
- publish `lobby.membership.rejected` intent
- Wire routes.
- Add service tests with in-memory stores, stubbed `UserService`, and stub
`RaceNameDirectory`.
Exit criteria:
- all three application operations pass tests.
- eligibility denial surfaces as `eligibility_denied` error.
- name conflict surfaces as `name_taken` error.
- all three notifications are published in success paths.
## ~~Stage 12.~~ Invite Flow (Private Games)
Status: implemented — see `lobby/docs/stage12-invite-flow-decisions.md`,
`lobby/internal/service/{createinvite,redeeminvite,declineinvite,revokeinvite}`,
the new `NewInviteID` extension across
`lobby/internal/ports/idgenerator.go` and
`lobby/internal/adapters/idgen/`, the in-process
`lobby/internal/adapters/invitestub/` test adapter, and the four invite
routes wired through `lobby/internal/api/publichttp/invites.go`.
Goal:
- implement the full private-game invite lifecycle
Tasks:
- Add `internal/service/createinvite/`:
- game type must be `private`, status `enrollment_open`
- invitee must not have an active invite or active membership in the game
- create `Invite{status: created, expires_at: game.enrollment_ends_at}`
- publish `lobby.invite.created` intent
- Add `internal/service/redeeminvite/`:
- invite status must be `created`, game status `enrollment_open`
- call `RaceNameDirectory.Check(raceName, actorUserID)`; fail if name is taken
by another user
- call `RaceNameDirectory.Reserve(gameID, userID, raceName)`
- create `Membership{status: active, canonical_key}`
- set invite `status=redeemed`
- publish `lobby.invite.redeemed` intent to owner
- trigger gap window open if `approved_count == max_players`
- Add `internal/service/declineinvite/`: set `status=declined`; no notification.
- Add `internal/service/revokeinvite/`: set `status=revoked`; no notification.
- Wire routes.
- Add service tests.
Exit criteria:
- redeem creates active membership without a separate approval step.
- race name is reserved atomically before membership creation.
- `lobby.invite.created` and `lobby.invite.redeemed` are published.
- decline and revoke produce no notification.
## ~~Stage 13.~~ Enrollment Automation Worker
Status: implemented — see `lobby/docs/stage13-enrollment-automation-decisions.md`,
`lobby/internal/worker/enrollmentautomation/`,
`lobby/internal/service/manualreadytostart/`,
`lobby/internal/service/shared/closeenrollment.go`, the
`GapActivationStore.Get` extension across
`lobby/internal/ports/gapactivationstore.go`,
`lobby/internal/adapters/redisstate/gapactivationstore.go`, and
`lobby/internal/adapters/gapactivationstub/store.go`, plus the
`POST /api/v1/lobby/games/{game_id}/ready-to-start` routes wired on both
ports through `lobby/internal/api/{publichttp,internalhttp}/ready_to_start.go`
and the worker registration in `lobby/internal/app/{wiring,runtime}.go`.
Goal:
- implement all automatic enrollment-to-ready-to-start transitions
Tasks:
- Add `internal/worker/enrollmentautomation/worker.go`:
- periodic ticker with `LOBBY_ENROLLMENT_AUTOMATION_INTERVAL` (default `30s`)
- on each tick, load all games in `enrollment_open` status
- for each game check:
1. deadline: `now >= enrollment_ends_at && approved_count >= min_players`
2. gap exhaustion: gap window is open and (`now >= gap_activated_at + start_gap_hours`
or `approved_count >= max_players + start_gap_players`)
- on transition to `ready_to_start`:
- atomically expire all `created` invites for the game
- publish `lobby.invite.expired` intents (one per expired invite)
- Add `internal/service/manualreadytostart/`:
- admin/owner command
- require `approved_count >= min_players`
- same expiry and notification side effects as auto-transition
- Add gap window activation: when `approved_count` reaches `max_players`, record
`gap_activated_at` in Redis.
- Add tests using a fake clock; cover all three auto-transition paths and the
boundary condition where the deadline fires but `min_players` is not yet met.
Exit criteria:
- all three auto-transition paths are covered by tests.
- invite expiry on enrollment close is tested.
- the worker is idempotent: running twice over the same state produces no
duplicate transitions or notifications.
## ~~Stage 14.~~ Game Start Flow
Status: implemented — see `lobby/docs/stage14-game-start-flow-decisions.md`,
`lobby/internal/ports/{runtimemanager,gmclient,streamoffsetstore}.go`,
the `RuntimeBinding` field on `lobby/internal/domain/game/model.go` and
the new `GameStore.UpdateRuntimeBinding` port method,
`lobby/internal/adapters/{runtimemanager,gmclient,redisstate/streamoffsetstore.go,runtimemanagerstub,gmclientstub,streamoffsetstub}/`,
`lobby/internal/service/{startgame,retrystartgame}/`,
`lobby/internal/worker/runtimejobresult/`, the `LOBBY_RUNTIME_STOP_JOBS_STREAM`
env var, the public/internal `start` and `retry-start` HTTP routes, the
removal of the obsolete `register-runtime` endpoint from
`lobby/api/internal-openapi.yaml`, and the `runtime_binding` schema
addition on the `GameRecord` shape across both OpenAPI contracts.
Goal:
- implement the full start sequence spanning Runtime Manager and Game Master
Tasks:
- Add `internal/ports/runtimemanager.go`: `RuntimeManager` interface
(`PublishStartJob(ctx, gameID string) error`,
`PublishStopJob(ctx, gameID string) error`).
- Add Redis stream adapter for `RuntimeManager` (write-only; publishes to
`runtime:start_jobs`).
- Add `internal/ports/gmclient.go`: `GMClient` interface
(`RegisterGame(ctx, req RegisterGameRequest) error`).
- Add `internal/adapters/gmclient/client.go`: HTTP client for GM registration.
- Add `internal/service/startgame/`:
- validate `ready_to_start`
- set status → `starting`
- publish start job to `RuntimeManager`
- Add `internal/worker/runtimejobresult/consumer.go`:
- consume `runtime:job_results` stream
- on failure result: set status → `start_failed`
- on success result:
- persist `runtime_binding` metadata on game record
- call `GMClient.RegisterGame` synchronously
- on GM success: set status → `running`; set `started_at`
- on GM failure/timeout: set status → `paused`; publish
`lobby.runtime_paused_after_start` intent
- on metadata persistence failure before GM call: publish stop job to
`RuntimeManager`; set status → `start_failed`
- Add `internal/service/retrystartgame/`: `start_failed → ready_to_start`.
- Wire consumer in `app/runtime.go`.
- Add tests with stubbed `RuntimeManager` and `GMClient`; cover all four
outcome paths.
Exit criteria:
- success path: game reaches `running` after container start and GM registration.
- paused path: GM unavailability produces `paused` + admin notification.
- failure path: container failure produces `start_failed`.
- orphan container path: metadata failure triggers stop job before `start_failed`.
- all paths covered by `go test ./... -race`.
## ~~Stage 14A.~~ Initial Player Stats Capture
Status: implemented — see `lobby/docs/stage14A-game-turn-stats-decisions.md`,
`lobby/internal/ports/gameturnstatsstore.go`,
`lobby/internal/adapters/redisstate/{gameturnstatsstore,codecs_gameturnstats}.go`,
and `lobby/internal/adapters/gameturnstatsstub/`.
Goal:
- freeze per-user `initial_planets` / `initial_population` at the first
`runtime_snapshot_update` after `starting → running`
Tasks:
- Add `internal/ports/gameturnstatsstore.go`: `GameTurnStatsStore` with
`SaveInitial(ctx, gameID, stats []PlayerInitialStats) error`,
`UpdateMax(ctx, gameID, stats []PlayerObservedStats) error`,
`Load(ctx, gameID) (GameTurnStatsAggregate, error)`,
`Delete(ctx, gameID) error` (invoked after capability evaluation).
- Add `internal/adapters/redisstate/gameturnstatsstore.go` keyed under
`lobby:game_turn_stats:<game_id>:<user_id>`; tests with `miniredis`.
- Extend the GM event DTO in `internal/worker/gmevents/` to decode
`player_turn_stats`.
- In the consumer, invoke `SaveInitial` once per game (no-op on subsequent
calls to preserve the first observation) and `UpdateMax` on every
`runtime_snapshot_update`.
Exit criteria:
- Initial stats do not change on subsequent snapshots.
- `UpdateMax` uses per-metric max semantics (never decreases).
- Idempotent replay of the GM stream produces the same aggregate.
## ~~Stage 15.~~ GM Runtime Stream Consumer
Status: implemented — see `lobby/docs/stage15-gm-events-consumer-decisions.md`
and `lobby/internal/worker/gmevents/consumer.go`. The consumer wires the
existing `LOBBY_GM_EVENTS_STREAM` and `LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT`
configuration through `lobby/internal/app/{wiring,runtime}.go` and hands
off to the Stage 15A capability evaluator on `game_finished`.
Goal:
- keep the denormalized runtime snapshot current using GM events and feed
Stage 14A stats + Stage 15A capability evaluation
Tasks:
- Add `internal/worker/gmevents/consumer.go`:
- consume `gm:lobby_events` stream
- on `runtime_snapshot_update` event:
- call `GameStore.UpdateRuntimeSnapshot` (turn, status, health)
- call `GameTurnStatsStore.SaveInitial` (first call only) and
`UpdateMax` using `player_turn_stats` (Stage 14A)
- on `game_finished` event:
- apply final snapshot; transition game to `finished`; set `finished_at`
- hand off to Stage 15A capability evaluator before acknowledging offset
- advance stream offset only after successful processing
- Add tests using `miniredis` with fake events; cover snapshot update,
game_finished, and replay idempotency.
Exit criteria:
- snapshot updates are applied without changing game status.
- `game_finished` transitions game to `finished`, sets `finished_at`, and
drives capability evaluation before offset advance.
- consumer restarts from the persisted offset without double-processing stats.
## ~~Stage 15A.~~ Capability Evaluation at Game Finish
Status: implemented — see `lobby/docs/stage15A-capability-evaluation-decisions.md`,
`lobby/internal/service/capabilityevaluation/service.go`,
`lobby/internal/ports/evaluationguardstore.go`,
`lobby/internal/adapters/redisstate/evaluationguardstore.go`, and
`lobby/internal/adapters/evaluationguardstub/`. Race-name notification
intents are wired through the `RaceNameIntents` port-shim and bound to
`capabilityevaluation.NoopRaceNameIntents{}` until Stage 24 lands the
real publisher.
Goal:
- decide per-member capability and resolve each active reservation into
`pending_registration` or immediate release when a game finishes
Tasks:
- Add `internal/service/capabilityevaluation/service.go`:
- input: finished game id, final stats aggregate from `GameTurnStatsStore`
- for each active membership:
- `capable = max_planets > initial_planets AND max_population > initial_population`
- capable ⇒ `RND.MarkPendingRegistration(gameID, userID, raceName,
finished_at + 30 days)` + intent
`lobby.race_name.registration_eligible`
- not capable ⇒ `RND.ReleaseReservation(gameID, userID, raceName)` +
(optional) intent `lobby.race_name.registration_denied`
- for `removed` / `blocked` memberships with outstanding reservations:
release immediately
- delete `GameTurnStatsStore` aggregate for the game after evaluation
- Hook the evaluator into `gmevents` consumer after `game_finished` processing.
- Tests for capable / not-capable / mixed rosters, and for idempotency on
replay.
Exit criteria:
- every `active` membership of a finished game produces exactly one RND
side effect (mark pending or release).
- replayed `game_finished` events do not mutate RND state after the first
successful evaluation (idempotency guard keyed on game id).
- intents publish only after the RND mutation commits.
## ~~Stage 16.~~ Paused State Management
Status: implemented — see `lobby/docs/stage16-paused-state-decisions.md`,
`lobby/internal/service/{pausegame,resumegame}/`, the `Ping` extension
on `lobby/internal/ports/gmclient.go` together with its real
(`lobby/internal/adapters/gmclient/client.go`) and stub
(`lobby/internal/adapters/gmclientstub/client.go`) implementations,
and the public/internal pause/resume handlers wired through
`lobby/internal/api/{publichttp,internalhttp}/pause_resume.go`,
`lobby/internal/app/{wiring,runtime}.go`.
Goal:
- implement voluntary pause and resume
Tasks:
- Add `internal/service/pausegame/`:
- actor must be admin or owner
- game must be `running`
- transition to `paused`
- Add `internal/service/resumegame/`:
- actor must be admin or owner
- game must be `paused`
- perform a synchronous GM liveness check (`GMClient.Ping` or equivalent)
- on GM reachable: transition to `running`
- on GM unreachable: return `service_unavailable`; game remains `paused`
- Wire routes.
- Add tests; cover GM-unreachable resume attempt.
Exit criteria:
- pause and resume operations enforce authorization and status invariants.
- resume does not transition to `running` when GM is unavailable.
## ~~Stage 17.~~ Member Operations
Status: implemented — see `lobby/docs/stage17-member-operations-decisions.md`,
`lobby/internal/service/{removemember,blockmember}`, the
`MembershipStore.Delete` extension across `lobby/internal/ports/membershipstore.go`,
`lobby/internal/adapters/redisstate/membershipstore.go`, and
`lobby/internal/adapters/membershipstub/store.go`, plus the public/internal
remove/block handlers wired through
`lobby/internal/api/{publichttp,internalhttp}/memberships.go` and
`lobby/internal/app/{wiring,runtime}.go`.
Goal:
- implement member removal and block
Tasks:
- Add `internal/service/removemember/`:
- before game start: drop membership; call
`RND.ReleaseReservation(gameID, userID, raceName)`
- after game start: set membership `status=removed`; keep the reservation
intact so `game_finished` evaluation decides its fate (Stage 15A)
- Add `internal/service/blockmember/`:
- set membership `status=blocked`
- race name reservation is preserved; Stage 15A releases it at
`game_finished`
- Wire routes.
- Add tests; cover pre-start and post-start removal semantics, including the
interaction with Stage 15A for post-start remove/block.
Exit criteria:
- removal before start releases the reservation immediately.
- removal/block after start keeps the reservation until `game_finished` and
Stage 15A releases it.
## ~~Stage 17A.~~ Race Name Registration Service
Status: implemented — see `lobby/docs/stage17A-race-name-registration-decisions.md`,
`lobby/internal/service/registerracename/`,
`lobby/internal/api/publichttp/racenames.go`, the
`writeErrorFromService` extension in
`lobby/internal/api/publichttp/games.go` (with the new
`shared.ErrSubjectNotFound` sentinel), the public OpenAPI surface
update in `lobby/api/public-openapi.yaml`, and the wiring through
`lobby/internal/app/{wiring,runtime}.go`.
Goal:
- let a player convert a `pending_registration` reservation into a permanent
registered race name
Tasks:
- Add `internal/service/registerracename/`:
- input: `{race_name, source_game_id}`; acting user from `X-User-ID`
- preconditions:
- canonical-key `pending_registration` exists for
`(source_game_id, user_id)` with `eligible_until > now`
- `UserService.GetEligibility` snapshot: active
`max_registered_race_names` > current registered count
(`0` denotes unlimited); `can_update_profile` is not required
- no `permanent_block` on the user
- commit: `RND.Register(source_game_id, user_id, race_name)`; emit intent
`lobby.race_name.registered`
- Wire route `POST /api/v1/lobby/race-names/register` on the public port.
- Tests: happy path, quota exceeded (`race_name_registration_quota_exceeded`),
pending expired (`race_name_pending_window_expired`), pending missing
(`subject_not_found`), permanent-blocked user (`forbidden`).
Exit criteria:
- Register call is atomic relative to RND reservation state.
- Quota logic matches the snapshot semantics (free=1, monthly=2, yearly=6,
lifetime=0 marker for unlimited).
- Intent emits only after successful commit.
## ~~Stage 17B.~~ Pending Registration Expiration Worker
Status: implemented — see `lobby/docs/stage17B-pending-registration-worker-decisions.md`,
`lobby/internal/worker/pendingregistration/`, the new
`PendingRegistrationConfig` group threaded through
`lobby/internal/config/{config,env,validation}.go`, and the worker
registration in `lobby/internal/app/{wiring,runtime}.go`.
Goal:
- release every `pending_registration` whose `eligible_until` has passed
Tasks:
- Add `internal/worker/pendingregistration/worker.go`:
- ticker with `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` (default `1h`)
- call `RND.ExpirePendingRegistrations(now)`
- for each expired entry: release the reservation and increment
`lobby.pending_registration.expirations`
- no notification (informational only)
- Tests using a fake clock and `miniredis`: boundary exactly at `eligible_until`,
batch of mixed-age entries, idempotent second tick.
Exit criteria:
- running the worker twice over the same state produces no extra side
effects.
- Stage 17A users who act before expiration still succeed (no race with the
worker).
## ~~Stage 17C.~~ Race Name Self-Service Reads
Status: implemented — see `lobby/docs/stage17C-race-name-self-service-decisions.md`,
`lobby/internal/service/listmyracenames/`, the new
`GET /api/v1/lobby/my/race-names` route on
`lobby/internal/api/publichttp/racenames.go` with the
`Dependencies.ListMyRaceNames` field on
`lobby/internal/api/publichttp/server.go`, the wiring through
`lobby/internal/app/{wiring,runtime}.go`, the `MyRaceNamesResponse`/
`PendingRaceName`/`RaceNameReservation` schemas added to
`lobby/api/public-openapi.yaml` (with the matching
`TestPublicSpecFreezesMyRaceNamesContract` in
`lobby/contract_openapi_test.go`), and the expanded
`lobby/README.md` §Race Name self-service section.
Goal:
- give the acting user a single view of their registered / pending / active
reservations
Tasks:
- Add `internal/service/listmyracenames/`:
- returns `{registered[], pending[], reservations[]}`
- `pending` carries `eligible_until_ms` and `source_game_id`
- `reservations` carries `game_id` and current `game_status`
- Wire `GET /api/v1/lobby/my/race-names`; update `public-openapi.yaml`.
- Visibility test: a user cannot read another user's RND state through this
endpoint.
Exit criteria:
- response shape matches `lobby/README.md` §Race Name self-service.
- operation avoids scanning the full RND (uses
`user_registered` / `user_reservations` indexes).
## ~~Stage 18.~~ Query and Read APIs
Status: implemented — see `lobby/docs/stage18-query-and-read-apis-decisions.md`,
the six new service packages
`lobby/internal/service/{getgame,listgames,listmemberships,listmygames,
listmyapplications,listmyinvites}/`, the shared pagination helper
`lobby/internal/service/shared/page.go`, the public-port handlers in
`lobby/internal/api/publichttp/{games,memberships,mylists}.go`, the
internal-port handlers in
`lobby/internal/api/internalhttp/{games,memberships}.go`, and the
wiring updates in `lobby/internal/app/{wiring,runtime}.go`.
Goal:
- implement all user-facing list and read operations with visibility enforcement
Tasks:
- Add `internal/service/getgame/`:
- enforce visibility rules: private game hidden from non-member non-owner users
- return runtime snapshot from denormalized fields
- Add `internal/service/listgames/`:
- public list: `enrollment_open`, `ready_to_start`, `running`, `finished` only
- authenticated user also sees their private game memberships
- Add `internal/service/listmemberships/`:
- admin, owner, or active member may list memberships of a game
- Wire `lobby.my_games.list`, `lobby.my_applications.list`, and
`lobby.my_invites.list` routes.
- Add tests for visibility rules.
Exit criteria:
- private game is not returned for non-member non-owner callers.
- public draft game is excluded from the public list.
- lists return correct entities for the authenticated user.
## ~~Stage 19.~~ Observability
Status: implemented — see `lobby/docs/stage19-observability-decisions.md`,
the extended `lobby/internal/telemetry/runtime.go` (15 instruments + 4
observable gauges + `RegisterGauges`), the new `IntentPublisher` /
`RaceNameDirectory` metric decorators in
`lobby/internal/adapters/{metricsintentpub,metricsracenamedir}/`, the
`GameStore.CountByStatus` extension across `lobby/internal/ports/gamestore.go`
and the redisstate / gamestub adapters, the new
`lobby/internal/ports/streamlagprobe.go` port with the redisstate adapter
and stub, the `httpcommon.RequestID` middleware wired on both HTTP
listeners, the `logging.ContextAttrs` helper plus the
`trace_id` / `span_id` rename in `logging/logger.go`, and the
service / worker threading of `*telemetry.Runtime` through every
status-transition / outcome / cascade success path.
Goal:
- instrument the service for operational support
Tasks:
- Add counters and gauges listed in `README.md` Observability section using the
OpenTelemetry SDK.
- Add structured log fields for all key operations (transitions, notification
publishes, enrollment automation triggers, stream consumer events).
- Propagate `request_id` and `trace_id` through all service calls and into
structured logs where available.
Exit criteria:
- process exports all listed metrics when a real or stdout OTEL exporter is
configured.
- key operations produce log entries with stable field names.
## ~~Stage 20.~~ Test Coverage and Documentation Alignment
Status: implemented — see
`lobby/docs/stage20-test-coverage-and-doc-alignment-decisions.md`,
the new `integration/lobbyuser/` package (4 boundary tests against the
real `user/cmd/userservice` binary), the new `integration/lobbynotification/`
package (4 scenario tests covering 8 of 11 lobby `notification:intents`
producer types), the updated `LOBBY_PENDING_REGISTRATION_TTL_HOURS`
clarification at `lobby/README.md:1130-1136`, the
`/healthz`/`/readyz` realignment in
`lobby/api/internal-openapi.yaml:49,66`, and the new
`TestPublicSpecDeclaresAllRegisteredRoutes` /
`TestInternalSpecDeclaresAllRegisteredRoutes` route-table contract tests
in `lobby/contract_openapi_test.go`. `ARCHITECTURE.md` §7 and §10 were
spot-checked and required no edits.
Goal:
- close the loop across service tests, boundary tests, and documentation
Tasks:
- Verify all `README.md` claims against the implemented behavior.
- Add integration tests in the `integration/` module for:
- `Lobby → User Service` eligibility check boundary
- `Lobby → Notification Service` intent publication for all seven types
- Align `lobby/api/public-openapi.yaml` and `internal-openapi.yaml` with the
final implemented routes.
- Run `go test ./... -race -cover` across the lobby module.
- Verify `ARCHITECTURE.md` still matches the final implementation.
Exit criteria:
- `go test ./... -race` passes for the lobby module and the integration module.
- no contradictions between `lobby/README.md`, `ARCHITECTURE.md`, and implemented
behavior.
## ~~Stage 21.~~ User Service: `user_name` + `display_name` refactor
Status: implemented — see `user/docs/stage21-user-name-display-name.md`,
`lobby/internal/domain/racename/`, and the Gateway boundary rename across
`pkg/schema/fbs/user.fbs`, `pkg/transcoder/user.go`, `pkg/model/user`, and
the integration + gateway contract tests.
Cross-service stage owned by `galaxy/user`. Must land before Stage 17A so the
eligibility snapshot carries `max_registered_race_names`. Can run in parallel
with Stages 09R and 10 once Stage 21.121.4 are complete.
Tasks:
- 21.1. Add `UserName` and `DisplayName` value types in
`user/internal/domain/common/types.go` (or an adjacent file). `UserName`
matches `player-<suffix>` with suffix 8 characters from a confusable-free
alphanumeric alphabet; `DisplayName` delegates validation to
`pkg/util/string.go:ValidateTypeName` and tolerates empty strings.
- 21.2. Replace `RaceName` with `UserName` and add `DisplayName` on
`UserAccount` in `user/internal/domain/account/model.go`. Delete
`RaceNameReservation` and `RaceNameCanonicalKey` types.
- 21.3. Rename `IDGenerator.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.