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.SanctionCodePermanentBlockjoins the supported sanction catalogue. The sanction collapses everycan_*eligibility marker tofalse, surfaces in the lobby-facing eligibility snapshot, and blocks every self-service read and write with409 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 returns404 subject_not_foundfor theuser_id.POST /api/v1/internal/users/{user_id}/deleteis the trusted command used byAdmin Serviceto soft-delete a regular user. The command is idempotent peruser_id: a second call after soft-delete returns404 subject_not_foundand does not re-emit the lifecycle event.ports.UserLifecyclePublisherplusadapters/redis/lifecycleeventspublish exactly oneuser.lifecycle.permanent_blockedevent on a successful permanent-block apply and exactly oneuser.lifecycle.deletedevent on a successfulDeleteUser. 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 ofuser: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, andopenapi.yaml.