// Package policy defines sanction, limit, and eligibility-domain entities used // by User Service. package policy import ( "fmt" "slices" "strings" "time" "galaxy/user/internal/domain/common" ) // SanctionCode identifies one supported sanction in the v1 policy catalog. type SanctionCode string const ( // SanctionCodeLoginBlock denies login. SanctionCodeLoginBlock SanctionCode = "login_block" // SanctionCodePrivateGameCreateBlock denies private-game creation. SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block" // SanctionCodePrivateGameManageBlock denies private-game management. SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block" // SanctionCodeGameJoinBlock denies game joining. SanctionCodeGameJoinBlock SanctionCode = "game_join_block" // SanctionCodeProfileUpdateBlock denies self-service profile/settings // mutations. SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block" ) // IsKnown reports whether SanctionCode belongs to the frozen v1 catalog. func (code SanctionCode) IsKnown() bool { switch code { case SanctionCodeLoginBlock, SanctionCodePrivateGameCreateBlock, SanctionCodePrivateGameManageBlock, SanctionCodeGameJoinBlock, SanctionCodeProfileUpdateBlock: return true default: return false } } // LimitCode identifies one user-specific limit code recognized by User // Service. type LimitCode string const ( // LimitCodeMaxOwnedPrivateGames limits how many private games the user may // own while the current entitlement is paid. LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games" // LimitCodeMaxPendingPublicApplications stores the total public-games budget // consumed together with current active public memberships when Game Lobby // derives remaining pending application headroom. LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications" // LimitCodeMaxActiveGameMemberships limits how many active public-game // memberships the user may hold at once. LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships" ) const ( // LimitCodeMaxActivePrivateGames is a retired legacy code recognized only // so old stored records do not break current reads. LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games" // LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code // recognized only so old stored records do not break current reads. LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests" // LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code // recognized only so old stored records do not break current reads. LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent" ) // IsKnown reports whether LimitCode belongs to the current supported write/API // catalog. func (code LimitCode) IsKnown() bool { return code.IsSupported() } // IsSupported reports whether LimitCode belongs to the current supported // write/API catalog. func (code LimitCode) IsSupported() bool { switch code { case LimitCodeMaxOwnedPrivateGames, LimitCodeMaxPendingPublicApplications, LimitCodeMaxActiveGameMemberships: return true default: return false } } // IsRetired reports whether LimitCode is a retired legacy code recognized // only for read compatibility with already stored history records. func (code LimitCode) IsRetired() bool { switch code { case LimitCodeMaxActivePrivateGames, LimitCodeMaxPendingPrivateJoinRequests, LimitCodeMaxPendingPrivateInvitesSent: return true default: return false } } // IsRecognized reports whether LimitCode is either currently supported or // retired-but-recognized for read compatibility. func (code LimitCode) IsRecognized() bool { return code.IsSupported() || code.IsRetired() } // EligibilityMarker identifies one derived eligibility boolean that may be // indexed for admin listing. type EligibilityMarker string const ( // EligibilityMarkerCanLogin tracks whether the user may currently log in. EligibilityMarkerCanLogin EligibilityMarker = "can_login" // EligibilityMarkerCanCreatePrivateGame tracks whether the user may create // a private game. EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game" // EligibilityMarkerCanManagePrivateGame tracks whether the user may manage // a private game. EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game" // EligibilityMarkerCanJoinGame tracks whether the user may join a game. EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game" // EligibilityMarkerCanUpdateProfile tracks whether the user may update // self-service profile/settings fields. EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile" ) // IsKnown reports whether EligibilityMarker belongs to the frozen v1 set. func (marker EligibilityMarker) IsKnown() bool { switch marker { case EligibilityMarkerCanLogin, EligibilityMarkerCanCreatePrivateGame, EligibilityMarkerCanManagePrivateGame, EligibilityMarkerCanJoinGame, EligibilityMarkerCanUpdateProfile: return true default: return false } } // SanctionRecordID identifies one sanction history record. type SanctionRecordID string // String returns SanctionRecordID as its stored identifier string. func (id SanctionRecordID) String() string { return string(id) } // IsZero reports whether SanctionRecordID does not contain a usable value. func (id SanctionRecordID) IsZero() bool { return strings.TrimSpace(string(id)) == "" } // Validate reports whether SanctionRecordID is non-empty, normalized, and // uses the frozen Stage 02 prefix. func (id SanctionRecordID) Validate() error { return validatePrefixedRecordID("sanction record id", string(id), "sanction-") } // LimitRecordID identifies one limit history record. type LimitRecordID string // String returns LimitRecordID as its stored identifier string. func (id LimitRecordID) String() string { return string(id) } // IsZero reports whether LimitRecordID does not contain a usable value. func (id LimitRecordID) IsZero() bool { return strings.TrimSpace(string(id)) == "" } // Validate reports whether LimitRecordID is non-empty, normalized, and uses // the frozen Stage 02 prefix. func (id LimitRecordID) Validate() error { return validatePrefixedRecordID("limit record id", string(id), "limit-") } // SanctionRecord stores one sanction history record. type SanctionRecord struct { // RecordID identifies the sanction history record. RecordID SanctionRecordID // UserID identifies the account that owns the sanction. UserID common.UserID // SanctionCode stores the sanction applied to the account. SanctionCode SanctionCode // Scope stores the machine-readable scope attached to the sanction. Scope common.Scope // ReasonCode stores the reason for the sanction mutation. ReasonCode common.ReasonCode // Actor stores the audit actor metadata for the apply mutation. Actor common.ActorRef // AppliedAt stores when the sanction becomes effective. AppliedAt time.Time // ExpiresAt stores the optional planned expiry of the sanction. ExpiresAt *time.Time // RemovedAt stores when the sanction was later removed explicitly. RemovedAt *time.Time // RemovedBy stores the audit actor metadata for the remove mutation. RemovedBy common.ActorRef // RemovedReasonCode stores the reason for the remove mutation. RemovedReasonCode common.ReasonCode } // Validate reports whether SanctionRecord satisfies the frozen structural // invariants that do not depend on a caller-supplied clock. func (record SanctionRecord) Validate() error { if err := record.RecordID.Validate(); err != nil { return fmt.Errorf("sanction record id: %w", err) } if err := record.UserID.Validate(); err != nil { return fmt.Errorf("sanction user id: %w", err) } if !record.SanctionCode.IsKnown() { return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode) } if err := record.Scope.Validate(); err != nil { return fmt.Errorf("sanction scope: %w", err) } if err := record.ReasonCode.Validate(); err != nil { return fmt.Errorf("sanction reason code: %w", err) } if err := record.Actor.Validate(); err != nil { return fmt.Errorf("sanction actor: %w", err) } if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil { return err } if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) { return common.ErrInvertedTimeRange } if record.RemovedAt == nil { if !record.RemovedBy.IsZero() { return fmt.Errorf("sanction removed by must be empty when removed at is absent") } if !record.RemovedReasonCode.IsZero() { return fmt.Errorf("sanction removed reason code must be empty when removed at is absent") } return nil } if record.RemovedAt.Before(record.AppliedAt) { return fmt.Errorf("sanction removed at must not be before applied at") } if err := record.RemovedBy.Validate(); err != nil { return fmt.Errorf("sanction removed by: %w", err) } if err := record.RemovedReasonCode.Validate(); err != nil { return fmt.Errorf("sanction removed reason code: %w", err) } return nil } // ValidateAt reports whether SanctionRecord also satisfies the current-time // Stage 02 invariant that `applied_at` must not be in the future. func (record SanctionRecord) ValidateAt(now time.Time) error { if err := record.Validate(); err != nil { return err } if now.IsZero() { return fmt.Errorf("sanction validation time must not be zero") } if record.AppliedAt.After(now.UTC()) { return fmt.Errorf("sanction applied at must not be in the future") } return nil } // IsActiveAt reports whether SanctionRecord is active at now according to the // frozen Stage 02 rules. func (record SanctionRecord) IsActiveAt(now time.Time) bool { now = now.UTC() switch { case now.IsZero(): return false case record.AppliedAt.After(now): return false case record.RemovedAt != nil: return false case record.ExpiresAt != nil && !record.ExpiresAt.After(now): return false default: return true } } // LimitRecord stores one user-specific limit history record. type LimitRecord struct { // RecordID identifies the limit history record. RecordID LimitRecordID // UserID identifies the account that owns the limit. UserID common.UserID // LimitCode stores which count-based limit is overridden. LimitCode LimitCode // Value stores the override value. Value int // ReasonCode stores the reason for the limit mutation. ReasonCode common.ReasonCode // Actor stores the audit actor metadata for the set mutation. Actor common.ActorRef // AppliedAt stores when the limit becomes effective. AppliedAt time.Time // ExpiresAt stores the optional planned expiry of the limit. ExpiresAt *time.Time // RemovedAt stores when the limit was later removed explicitly. RemovedAt *time.Time // RemovedBy stores the audit actor metadata for the remove mutation. RemovedBy common.ActorRef // RemovedReasonCode stores the reason for the remove mutation. RemovedReasonCode common.ReasonCode } // Validate reports whether LimitRecord satisfies the structural invariants // that do not depend on a caller-supplied clock. Retired legacy limit codes // remain recognized here so already stored records still decode safely. func (record LimitRecord) Validate() error { if err := record.RecordID.Validate(); err != nil { return fmt.Errorf("limit record id: %w", err) } if err := record.UserID.Validate(); err != nil { return fmt.Errorf("limit user id: %w", err) } if !record.LimitCode.IsRecognized() { return fmt.Errorf("limit code %q is unsupported", record.LimitCode) } if record.Value < 0 { return fmt.Errorf("limit value must not be negative") } if err := record.ReasonCode.Validate(); err != nil { return fmt.Errorf("limit reason code: %w", err) } if err := record.Actor.Validate(); err != nil { return fmt.Errorf("limit actor: %w", err) } if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil { return err } if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) { return common.ErrInvertedTimeRange } if record.RemovedAt == nil { if !record.RemovedBy.IsZero() { return fmt.Errorf("limit removed by must be empty when removed at is absent") } if !record.RemovedReasonCode.IsZero() { return fmt.Errorf("limit removed reason code must be empty when removed at is absent") } return nil } if record.RemovedAt.Before(record.AppliedAt) { return fmt.Errorf("limit removed at must not be before applied at") } if err := record.RemovedBy.Validate(); err != nil { return fmt.Errorf("limit removed by: %w", err) } if err := record.RemovedReasonCode.Validate(); err != nil { return fmt.Errorf("limit removed reason code: %w", err) } return nil } // ValidateAt reports whether LimitRecord also satisfies the current-time Stage // 02 invariant that `applied_at` must not be in the future. func (record LimitRecord) ValidateAt(now time.Time) error { if err := record.Validate(); err != nil { return err } if now.IsZero() { return fmt.Errorf("limit validation time must not be zero") } if record.AppliedAt.After(now.UTC()) { return fmt.Errorf("limit applied at must not be in the future") } return nil } // IsActiveAt reports whether LimitRecord is active at now according to the // frozen Stage 02 rules. func (record LimitRecord) IsActiveAt(now time.Time) bool { now = now.UTC() switch { case now.IsZero(): return false case record.AppliedAt.After(now): return false case record.RemovedAt != nil: return false case record.ExpiresAt != nil && !record.ExpiresAt.After(now): return false default: return true } } // ActiveSanctionsAt returns the active sanctions at now, sorted // deterministically by `sanction_code`. The function returns an error when the // input contains structurally invalid records or more than one active sanction // for the same `user_id + sanction_code`. func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) { active := make([]SanctionRecord, 0, len(records)) seen := make(map[SanctionCode]struct{}, len(records)) for _, record := range records { if err := record.ValidateAt(now); err != nil { return nil, err } if !record.IsActiveAt(now) { continue } if _, ok := seen[record.SanctionCode]; ok { return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode) } seen[record.SanctionCode] = struct{}{} active = append(active, record) } slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int { return strings.Compare(string(left.SanctionCode), string(right.SanctionCode)) }) return active, nil } // ActiveLimitsAt returns the active limits at now, sorted deterministically by // `limit_code`. Retired legacy limit codes are ignored so historical records // stored under the old catalog do not affect current effective reads. The // function returns an error when the input contains structurally invalid // records or more than one active current limit for the same // `user_id + limit_code`. func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) { active := make([]LimitRecord, 0, len(records)) seen := make(map[LimitCode]struct{}, len(records)) for _, record := range records { if err := record.ValidateAt(now); err != nil { return nil, err } if !record.IsActiveAt(now) { continue } if !record.LimitCode.IsSupported() { continue } if _, ok := seen[record.LimitCode]; ok { return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode) } seen[record.LimitCode] = struct{}{} active = append(active, record) } slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int { return strings.Compare(string(left.LimitCode), string(right.LimitCode)) }) return active, nil } func validatePrefixedRecordID(name string, value string, prefix string) error { switch { case strings.TrimSpace(value) == "": return fmt.Errorf("%s must not be empty", name) case strings.TrimSpace(value) != value: return fmt.Errorf("%s must not contain surrounding whitespace", name) case !strings.HasPrefix(value, prefix): return fmt.Errorf("%s must start with %q", name, prefix) case len(value) == len(prefix): return fmt.Errorf("%s must contain opaque data after %q", name, prefix) default: return nil } }