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.
The compliance requirement
Section titled “The compliance requirement”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.
How it works
Section titled “How it works”One lookup per session
Section titled “One lookup per session”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.
Normalizing the answer
Section titled “Normalizing the answer”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.
Retries and failure
Section titled “Retries and failure”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.
Fail-closed features
Section titled “Fail-closed features”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.
Settled
Section titled “Settled”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.
Where the gate applies
Section titled “Where the gate applies”LootKit Paid tables
Section titled “LootKit Paid tables”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
Section titled “Transactions”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.
Receipts
Section titled “Receipts”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 Policy attached
Section titled “Without Policy attached”Without an attached Policy none of the gates can run:
Paidloot tables roll for everyone. Each LootKit logs one warning, the first time aPaidtable rolls, that no Policy is attached.Transactions.atomicdoes 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:
--!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() endend
return EggServiceThe 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:
--!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 screenself.Net.EggsAllowed.on(function(allowed: boolean) EggButton.Visible = allowedend)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 itemsend
-- A trade of items nobody bought with Robux or Robux-bought currency skips the gateTransactions.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. |
Hooks & bus topics
Section titled “Hooks & bus topics”| 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. |