Skip to content

Policy

Loads each player's PolicyService rules once per session and keeps paid random items, paid trading and every other policy feature refused until they load.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Policy
Since
v0.9.0
Source
Policy.luau

Policy loads each player’s PolicyService rules once per session and tells the rest of the framework whether that player may use a feature. Roblox requires experiences that sell paid random items, or let players trade items bought with Robux, to honor those rules per player. Policy.attach sets kernel.Policy, and three modules read it: LootKit refuses Paid tables, Transactions refuses paid trades, and Receipts waits for the buyer’s lookup before it grants. Every feature reads as not allowed until the player’s rules have loaded, and stays that way if they never load.

Two PolicyService fields decide whether a player may use monetized randomness and trading:

  • ArePaidRandomItemsRestricted. When true, the player may not use a paid random item generator: an egg, crate, pack or gacha opened with Robux or with currency bought with Robux.
  • IsPaidItemTradingAllowed. When false, the player may not trade items bought with Robux or with Robux-bought currency.

A game with either feature must check these fields for every player. In ChloeKernel that means attaching Policy, marking every loot table the player opens with Robux or Robux-bought currency Paid = true, and running paid trades through Transactions.atomic with its default Paid = true. A game with neither feature can skip Policy.

Policy.attach registers a session handler, which also replays sessions that already exist, and queues each session for one PolicyService:GetPolicyInfoForPlayerAsync(player) call. A pump keeps at most MaxInFlight lookups outstanding (default 4); the engine errors with “called too many times” past 100 in flight. Each lookup runs on its own thread inside a pcall.

When a lookup returns a table, the answer is normalized and frozen, stored on session.Policy, indexed by user id for allowsUser, and announced as the milestone Policy.Loaded(session, policy). Subscribing through Milestones.on(kernel, "Policy.Loaded", fn) replays it for sessions whose policy loaded before the subscription. A lookup that finishes after the player left is discarded, and a queued session that ends is never looked up.

The stored table keeps the answer’s string keys whose values are booleans, numbers or strings. It rebuilds AllowedExternalLinkReferences as a frozen list of strings (the engine documents that field as legacy and always empty). Every documented boolean the service omitted, or returned as something other than a boolean, takes its restrictive value:

Field Restrictive value
ArePaidRandomItemsRestricted true
IsSubjectToChinaPolicies true
IsPaidItemTradingAllowed, AreAdsAllowed, IsContentSharingAllowed false
IsEligibleToPurchaseSubscription, IsEligibleToPurchaseCommerceProduct false
IsEndlessContentLoadAllowed, IsEndlessContentAutoplayAllowed, IsPhotoToAvatarAllowed false

A missing field therefore never reads as permission.

A lookup that errors, or returns something other than a table, is retried after RetrySeconds * 2^(attempt - 1) seconds, capped at 30 s: 2, 4 and 8 s with defaults. The retry is a Scheduler deadline at Priority.Background, and a session that ends cancels it. After Retries retries (3 by default, so 4 attempts in all) Policy gives up for that session: it logs one warning, publishes Policy.Failed(session, reason), and every feature stays refused until the player rejoins.

rules:allows(session, feature) reads one field of the stored table. It returns false while the policy has not loaded, after a failed lookup, and when the field is not a boolean. An unknown feature name raises an error, so a typo cannot read as a refusal.

Feature Field Allowed when
PaidRandomItems ArePaidRandomItemsRestricted the field is false
PaidItemTrading IsPaidItemTradingAllowed the field is true
Ads AreAdsAllowed the field is true
Subscriptions IsEligibleToPurchaseSubscription the field is true
CommerceProducts IsEligibleToPurchaseCommerceProduct the field is true
ContentSharing IsContentSharingAllowed the field is true
EndlessContentLoad IsEndlessContentLoadAllowed the field is true
EndlessContentAutoplay IsEndlessContentAutoplayAllowed the field is true
PhotoToAvatar IsPhotoToAvatarAllowed the field is true

rules:allowsUser(userId, feature) is the same check by user id, for code that holds profiles instead of sessions. It only knows players whose policy loaded on this server and whose session is still live.

rules:settled(session) is true once the lookup has an answer: the policy loaded, or the last retry failed. Code that must not act on the fail-closed default while a lookup is still running waits for settled instead of isLoaded, because a failed lookup never loads.

A loot table marked Paid = true rolls only per player. roll(), which has no player, raises for a Paid table. rollFor(session, name) and award(session, name) first check allows(session, "PaidRandomItems") through options.Policy or kernel.Policy. A refused roll returns ({}, "PolicyRestricted") from rollFor and ({}, {}, "PolicyRestricted") from award, publishes Loot.Refused(session, tableName, "PolicyRestricted"), and advances no pity counters. Only the table the player pays to open is checked; its nested tables roll in place.

Transactions.atomic(a, b, mutate, options?) finds its gate in options.Policy, else the kernel.Policy of the profiles’ DataDriver. With a gate, the trade returns { Ok = false, Reason = "PolicyRestricted" } before touching either profile unless every owner in both profiles’ Meta.UserIds passes allowsUser(userId, "PaidItemTrading"). Because allowsUser knows only policies loaded on this server, a profile whose owner has no live session here (an offline player’s record) refuses too. Pass { Paid = false } for a trade that moves nothing bought with Robux or Robux-bought currency; it skips the gate.

A developer product bought in the first seconds after joining can arrive before the buyer’s lookup finishes, and running its grant then would refuse an eligible player’s paid egg. So when kernel.Policy exists and the buyer’s lookup has not settled, Receipts waits: it wakes on Policy.Loaded, Policy.Failed or the buyer’s Kernel.SessionEnd, and re-checks every second, until the lookup settles or the buyer leaves. A buyer who leaves gets NotProcessedYet, and the receipt stays open for the next delivery. After the lookup settles the grant runs. If it rolls a Paid table for a restricted buyer, the Loot.Refused it causes declines the purchase and the receipt stays open, unless the grant returns true.

Without an attached Policy none of the gates can run:

  • Paid loot tables roll for everyone. Each LootKit logs one warning, the first time a Paid table rolls, that no Policy is attached.
  • Transactions.atomic does not check paid trading.
  • Receipts does not wait for any lookup.

That configuration is not compliant for a game with paid random items or paid trading. The warning is the only signal, so treat it as a bug.

Attach Policy once, in the service that owns the paid feature, and mark the table:

src/Server/Game/Services/EggService.luau
--!strict
-- EggService: hatches Robux eggs from a Paid loot table and tells each client whether to offer them.
local ServerScriptService = game:GetService("ServerScriptService")
local InventoryKit = require(ServerScriptService.ChloeKernelServer.Kits.InventoryKit)
local Items = require(script.Parent.Parent.Config.Items)
local LootKit = require(ServerScriptService.ChloeKernelServer.Kits.LootKit)
local Milestones = require(ServerScriptService.ChloeKernelServer.Milestones)
local Policy = require(ServerScriptService.ChloeKernelServer.Policy)
local EggService = {
Name = "EggService",
}
function EggService.init(self: any, kernel: any)
self.Kernel = kernel
self.Rules = Policy.attach(kernel) -- sets kernel.Policy for LootKit, Transactions and Receipts
self.Inventory = InventoryKit.attach(kernel, { Items = Items })
self.Loot = LootKit.attach(kernel, {
Inventory = self.Inventory,
Tables = {
Egg = {
Paid = true, -- opened with Robux: rolls only for players whose policy allows paid random items
Entries = {
{ Weight = 90, Id = "Cat" },
{ Weight = 9, Id = "Fox" },
{ Weight = 1, Id = "Dragon" },
},
},
},
})
end
function EggService.start(self: any)
-- Milestones.on replays policies that loaded before this subscription
self.PolicyConnection = Milestones.on(self.Kernel, "Policy.Loaded", function(session)
self.Net.EggsAllowed.send(session.Player, self.Rules:allows(session, "PaidRandomItems"))
end)
end
function EggService.stop(self: any)
if self.PolicyConnection then
self.PolicyConnection:disconnect()
end
end
return EggService

The channel goes in src/Shared/Game/Channels.luau:

EggsAllowed = { Kind = "State", Schema = { "Boolean8" } },

The developer product grant opens the egg. It returns nothing, so a refusal decides the receipt:

src/Server/Game/Data/Products.luau
--!strict
-- Developer product grants: [productId] = function(session, receiptInfo).
-- Grants must not yield and may only change session.Profile.Data; Receipts saves before acknowledging.
local EggService = require(script.Parent.Parent.Services.EggService)
local EggProductId = 1234567890
return {
[EggProductId] = function(session, _receiptInfo)
-- A PolicyRestricted refusal (Loot.Refused) or a full bag (Inventory.Overflow) for the
-- buyer declines the purchase and the receipt stays open
EggService.Loot:award(session, "Egg")
end,
} :: { [number]: (session: any, receiptInfo: any) -> () }

The client shows the egg offer only when the server said so. A player whose lookup failed never receives EggsAllowed, so the client’s default must be hidden:

-- In a controller or screen
self.Net.EggsAllowed.on(function(allowed: boolean)
EggButton.Visible = allowed
end)

A paid trade needs nothing extra once Policy is attached. Check the reason to tell the players why it failed:

local Result = Transactions.atomic(SellerProfile, BuyerProfile, moveTradedItems)
if Result.Reason == "PolicyRestricted" then
-- one of the owners may not trade paid items
end
-- A trade of items nobody bought with Robux or Robux-bought currency skips the gate
Transactions.atomic(SellerProfile, BuyerProfile, moveFreeItems, { Paid = false })

The other features are direct checks, for example Rules:allows(session, "Ads") before placing an immersive ad, or Rules:get(session) for the raw frozen table (IsSubjectToChinaPolicies).

Member Description
Policy.attach(kernel, options?) Returns the Policy instance and sets kernel.Policy. Queues one lookup per session, including sessions that already exist.
rules:get(session) The frozen, normalized PolicyService table, or nil while it has not loaded.
rules:isLoaded(session) True once session.Policy is set.
rules:settled(session) True once the lookup has an answer: loaded, or failed after its last retry.
rules:allows(session, feature) Whether the loaded policy allows feature. False until loaded and after a failure. Raises on an unknown feature.
rules:allowsUser(userId, feature) The same check by user id, for players with a loaded policy and a live session on this server.
rules:destroy() Disconnects, cancels pending retries, clears the queue and unsets kernel.Policy when it points here.

attach options:

Option Default Description
MaxInFlight 4 Lookups outstanding at once. The engine errors past 100.
Retries 3 Attempts after the first failure.
RetrySeconds 2 First retry delay, doubling per attempt up to 30 s.
Service PolicyService A double for specs.
Kind Name Payload Notes
Bus (published, milestone) Policy.Loaded session, policy The player’s rules loaded. Milestones.on replays it for sessions that loaded earlier.
Bus (published) Policy.Failed session, reason The lookup failed after its last retry. Every feature stays refused for the session.
Bus (consumed) Kernel.SessionEnd session, ... Drops the session from the queue, cancels its retry and forgets its user id.
Bus (published by LootKit) Loot.Refused session, tableName, "PolicyRestricted" A Paid roll refused by the gate.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.