# Stage 22 — `permanent_block` Sanction and `DeleteUser` Soft-Delete Stage 22 lands in `galaxy/user` the terminal-state sanction `permanent_block`, the soft-delete command `DeleteUser`, and the dedicated Redis Stream `user:lifecycle_events` that feeds the Stage 23 `Game Lobby` Race Name Directory cascade release. ## Outcomes - `policy.SanctionCodePermanentBlock` joins the supported sanction catalogue. The sanction collapses every `can_*` eligibility marker to `false`, surfaces in the lobby-facing eligibility snapshot, and blocks every self-service read and write with `409 conflict`. Admin reads still return the record so operators can observe the state. - `LimitCodeMaxRegisteredRaceNames` — already introduced by Stage 21 — is now wired through the admin list index and the lifecycle write catalogue has no further gap (tracked here so future stages do not re-open task 22.2). - `UserAccount.DeletedAt` (`*time.Time`) represents the soft-delete state of a regular-user record. When set, every external read path returns `404 subject_not_found` for the `user_id`. - `POST /api/v1/internal/users/{user_id}/delete` is the trusted command used by `Admin Service` to soft-delete a regular user. The command is idempotent per `user_id`: a second call after soft-delete returns `404 subject_not_found` and does not re-emit the lifecycle event. - `ports.UserLifecyclePublisher` plus `adapters/redis/lifecycleevents` publish exactly one `user.lifecycle.permanent_blocked` event on a successful permanent-block apply and exactly one `user.lifecycle.deleted` event on a successful `DeleteUser`. Both events carry `{event_type, user_id, occurred_at_ms, source, actor_type, actor_id?, reason_code, trace_id?}`. ## Decisions ### 1. Dedicated Redis Stream **Decision.** Lifecycle events live on their own stream (default `user:lifecycle_events`) rather than extending the shared `user:domain_events` stream. **Why.** The consumer model is different: `Game Lobby` treats lifecycle events as source-of-truth triggers for RND cascade release and wants a narrow, at-least-once stream it can pin an offset on. Co-locating the events with high-volume domain events (profile, settings) would force the consumer to filter a much larger firehose and would couple retention policies. A dedicated stream keeps the contract small. ### 2. Soft-Delete Preserves the Record **Decision.** `DeleteUser` sets `UserAccount.DeletedAt` but preserves the account record, the email/user-name lookup keys, and the admin indexes. **Why.** Audit. Compliance and support workflows need to resolve a `user_id` back to its last known `email`, `user_name`, and tariff state after the user is gone. Hard-delete would break support. External reads still surface the account as `subject_not_found` so the live contract is clean. ### 3. `DeleteUser` Second-Call Semantics — `404`, Not `200` **Decision.** A second `DeleteUser` call for the same `user_id` returns `404 subject_not_found` rather than a cosmetic `200 OK` echoing the existing `deleted_at`. **Why.** This is the exit criterion in `lobby/PLAN.md` §Stage 22: "a second call after soft-delete returns `subject_not_found`". It keeps the `user_id` subject semantics uniform across every external surface (auth, self-service, admin-read, lobby-eligibility, `DeleteUser` itself) — every post-delete access converges on the same error code. It also avoids the footgun of a "delete" that appears to succeed after the account is already gone. ### 4. Soft-Deleted Email Returns `blocked`, Not `existing` **Decision.** `Store.ResolveByEmail` and `Store.EnsureByEmail` return the `blocked` outcome with `reason_code=account_deleted` when the email lookup resolves to a soft-deleted account. They do not try to free the email lookup or reassign the `user_id` to a new account. **Why.** The alternative — reclaiming the email on soft-delete so ensure-by-email can mint a fresh `user_id` — requires coordinated mutation of multiple lookup keys across the delete path. The simpler rule "deleted emails stay blocked" mirrors common platform practice, guarantees stable audit trails (the old `user_id` remains resolvable by id), and sidesteps any ambiguity about which account an authenticator should re-bind to. If a compliance event demands the email be released, an explicit `unblock-by-email` command can be added later without changing the Stage 22 contract. ### 5. Removing `permanent_block` Does Not Emit a Lifecycle Event **Decision.** The `RemoveSanction` path does not publish a `user.lifecycle.permanent_blocked` event or any lifecycle event when it clears a `permanent_block` record. **Why.** The spec phrasing in `lobby/PLAN.md` §22.4 is "emitted when `SanctionCodePermanentBlock` becomes active on a user". The inverse transition (admin un-blocks) is administratively supported, but Stage 23 does not currently need a signal to "un-cascade" RND state — that decision is deferred. Emitting a complementary `permanent_block_removed` event now would lock us into a consumer-facing shape before its consumer exists. ### 6. Publishing Is Post-Commit, Best-Effort **Decision.** Both apply-permanent-block and delete publish the lifecycle event after the persistence commit succeeds. Failure to publish logs and increments `user.event_publication_failures` but does not roll back the commit or fail the HTTP request. **Why.** Matches the existing sanction/limit publisher shape (`policysvc.publishSanctionChanged` et al.) and the global rule in `galaxy/AGENTS.md`: never publish after a rollback; always publish after commit. Rolling back on a publisher failure would leak partial state through subsequent reads and invite inconsistent retries. ### 7. Admin Listing Excludes Soft-Deleted Accounts by Default **Decision.** `adminusers.Lister` silently skips candidates whose aggregate load returns `subject_not_found` (the effect of `Loader.Load` for soft-deleted accounts). The OpenAPI schema exposes `deleted_at` on `AccountView` for cases where a caller already holds the record. **Why.** Stage 22 needs the default behaviour to converge with the exit-criterion "external admin-read of a deleted user returns `subject_not_found`". A dedicated `deleted` filter (for admin workflows that explicitly want the audit trail) is out of scope — it can be added as a separate task without changing the core contract. ## Cross-References - `galaxy/lobby/PLAN.md` §Stage 22 drives the exit criteria; §Stage 23 is the downstream lobby consumer of `user:lifecycle_events`. - `galaxy/ARCHITECTURE.md` §3 (User Service) and §7 (Race Name Directory) describe the external-facing contract realised by this stage. - Related module files: `internal/domain/policy/model.go`, `internal/domain/account/model.go`, `internal/service/accountdeletion/service.go`, `internal/service/policysvc/service.go`, `internal/adapters/redis/lifecycleevents/publisher.go`, `internal/api/internalhttp/handler.go`, and `openapi.yaml`.