Files
galaxy-game/user/docs/stage22-permanent-block-delete-user.md
T
2026-04-25 23:20:55 +02:00

6.8 KiB

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.