Hooks
Named, priority-ordered validation chains that fail closed, so a crashing validator rejects the action instead of allowing it.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Hooks .HookRegistry - Since
- v0.1.0
- Source
- Hooks/HookRegistry.luau
The hook registry is how the framework asks “may this happen?”. A hook point is a name with a priority-ordered chain of
handlers that share one mutable context table, and any handler can cancel the action by returning false. The chain
fails closed: a validator that crashes counts as a rejection, so a bug in permission logic can deny a player an action
but never grant one. Every network intent and request passes through a hook chain before its handler runs (see
NetDriver), and the fuzzer reads the crash tracebacks chains
return. Where hooks ask permission, the Bus and Signal only announce facts.
Mental model
Section titled “Mental model”A registry is a map of points, and a point is a list of handlers:
- Each handler is
{ Fn, Priority, Order }.Orderis a registry-wide registration counter, so the sort is stable: ties on priority keep registration order. - Sorting is lazy. Adding or removing a handler marks the point dirty, and the next
firesorts once and takes a copy of the sorted list. The copy is what runs, so a handler that unregisters itself or another handler mid-fire cannot make the chain skip the next validator. Changes made during a fire take effect on the next fire. fire(name, context)walks that copy in order. Each handler runs underxpcallwithdebug.tracebackand receives the same context table:- returns
nilor anything butfalse: the chain continues; - returns
false: the chain stops andfirereturnsfalse, context; - throws: a
[HookRegistry] hook "<name>" handler errored:warning is logged (unlessWarnOnError = false) and, unless the point isFailOpen, the chain stops andfirereturnsfalse, context, traceback.
- returns
- Firing a point with no handlers, including a name never used anywhere, returns
true, context. Points cost nothing to invent; there is no registration step for your own names.
A cancelled chain returns the context as it is, so changes earlier handlers made before the cancel are visible to the caller. That is the place for a rejection reason.
The third return value is new in 0.9.0. It is the crashing handler’s traceback as a string, returned only when a handler
threw on a fail-closed point. A false return gives two values. The Studio fuzz gate
collects it into ValidatorErrors and fails on it; before 0.9.0 the gate could not tell a crash from a rejection.
Priority order
Section titled “Priority order”Lower numbers run first. The framework uses the front of every chain:
| Priority | Who | What runs there |
|---|---|---|
5 |
Registry Validate rules |
Declarative argument checks (Range, OneOf, MaxLength) compiled from channel definitions. Mechanical rejection before any game code sees the payload. |
10 and up |
Game validators | Your permission logic, cheapest checks first. |
50 |
Kit gates | InteractionKit’s enabled, distance, line-of-sight and cooldown gate on Intent.Interact. Game rules at lower numbers run before it without forking the kit. |
100 |
Default | Handlers registered without a priority. |
Try it
Section titled “Try it”Reorder the chain by editing priorities, make a validator return false or throw, and fire. The model follows
HookRegistry.fire: ascending priority, ties in registration order, the first false stops the chain, and a
throw rejects with its traceback unless the point is FailOpen. Remove every validator to see NetDriver’s
fail-closed absence rule take over.
The channel declares its mechanical rules, and the Range rule becomes a priority-5 validator on Intent.UseItem:
--!strict-- Every network channel the game uses.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Registry = require(ReplicatedStorage.ChloeKernel.Net.Registry)
return Registry.define({ UseItem = { Kind = "Intent", Schema = { "NumberU16" }, RateLimit = 5, Validate = { [1] = { Range = { 1, 500 } } } },})Game rules go on the same chain at higher numbers, a catalog check ahead of a state gate:
--!strict-- ItemService: validates and applies item use.
local ItemCatalog = { [1] = { Cooldown = 2 }, [2] = { Cooldown = 10 },}
local ItemService = { Name = "ItemService",}
function ItemService.init(self: any, kernel: any) self.Kernel = kernelend
function ItemService.start(self: any) local Hooks = self.Kernel.Hooks -- The removal functions go in a scope, which calls them when it closes self.Lifetime = self.Kernel:createScope() -- Validator 1: the id must exist in the catalog, priority 10 self.Lifetime:add(Hooks:on("Intent.UseItem", function(context) if ItemCatalog[context.Args[1]] == nil then return false -- unknown id: rejected, the handler never runs end return nil end, 10)) -- Validator 2: server-side state, never client claims, priority 20 self.Lifetime:add(Hooks:on("Intent.UseItem", function(context) local LastUse = context.Session.Data.LastUse or 0 if os.clock() - LastUse < ItemCatalog[context.Args[1]].Cooldown then return false end return nil end, 20)) self.Net.UseItem.on(function(session, itemId: number) session.Data.LastUse = os.clock() self.Kernel.Bus:publish("Item.Used", session, itemId) end)end
function ItemService.stop(self: any) if self.Lifetime then self.Lifetime:destroy() endend
return ItemServiceFiring a chain yourself works the same way; hooks are not tied to networking:
local Passed, Context = kernel.Hooks:fire("Trade.CanOffer", { Seller = SellerSession, Buyer = BuyerSession, ItemId = ItemId,})if not Passed then returnend-- Validators may have added to the context, for example Context.Tax from an economy ruleapplyTrade(Context)Validators may change the context as well as veto. Intent.<Channel> validators can rewrite context.Args (clamp a
magnitude, canonicalize a string), and the intent handler receives the changed arguments. One chain rejects the
impossible and corrects the merely wrong.
The removal function is the return value of on:
local Off = kernel.Hooks:on("Intent.Jump", function(context) return context.Session.Data.Stamina > 0end)Off() -- handlerCount("Intent.Jump") drops by oneFail-closed, and why
Section titled “Fail-closed, and why”The xpcall around every handler turns crashes into rejections:
- A validator exists because the action is dangerous without it.
- A validator that crashed has not validated anything.
- So the action must not proceed. Failing open would turn any bug in permission logic, such as a nil index on a malformed payload an attacker crafted to cause it, into permission.
The Net layer extends the rule to absence. An intent or request channel with no validator registered and no
Open = true rejects every payload, whether a handler or a Bus subscriber consumes it. A missing validator is not
permission. That closes the unguarded GrantCoins channel whose handler trusts a client-supplied amount because nobody
remembered to guard it. Validate rules in the channel definition count as a validator, and a channel that really
needs no authorization (a payload-less ready-up) says so with Open = true. The channel logs a one-time
[NetDriver] ... fail-closed warning when it rejects for this reason, the server kernel warns at start() for every
such channel, and Net:auditValidators() lists them. Before 0.9.0 the rule applied only to channels that had a handler,
so an intent consumed only through its Bus topic reached subscribers unvalidated. Details are on
NetDriver.
Fail-open points
Section titled “Fail-open points”Some points must not reject on error, and they opt out explicitly:
kernel.Hooks:definePoint("Telemetry.Event", { FailOpen = true }) -- logging must never block gameplayThe framework uses FailOpen in two places, both deliberate:
- Observational points:
Net.RateLimited,Net.FloodDetected,AntiExploit.MovementViolation. They fire after the framework already acted (the message was dropped, the strike recorded), and handlers attach policy such as a ban. A crashing policy handler must not suppress the others. - Kit gate points:
Inventory.CanEquip,Inventory.CanUse,Craft.CanCraft,Companion.CanSummon,Dialogue.CanBegin,Melee.CanHit,Party.CanInvite,Weapon.CanDamage. They carry optional game rules (tournament locks, team checks) on top of transport chains that are already fail-closed. The kit’s default answer is “allowed”, and a crashing custom rule falls back to that default instead of breaking equipping game-wide. The pattern and each kit’s gates are on the kits overview.
kernel:securityAudit() reports any Intent. or Request. point set to FailOpen as a High finding, so the escape
hatch stays visible.
API reference
Section titled “API reference”| Member | Description |
|---|---|
HookRegistry.new(options?): HookRegistry |
options = { WarnOnError: boolean? }, default true. The kernel creates one at boot as kernel.Hooks. |
hooks:definePoint(name, options?) |
options = { FailOpen: boolean? }. Points are fail-closed by default; with no options this only ensures the point exists. |
hooks:on(name, fn, priority?): () -> () |
Registers fn(context) -> boolean?. Default priority 100; ties keep registration order. Returns the removal function. |
hooks:fire(name, context?): (boolean, context, string?) |
Runs the chain on context (default {}). true when every handler passed; false when one returned false or threw on a fail-closed point. The context comes back either way, and the third value is the traceback of a handler that threw. |
hooks:handlerCount(name): number |
Handlers on a point. NetDriver’s unguarded-channel check reads this. |
hooks:listPoints(): { { Name, FailOpen, Handlers } } |
A snapshot of every point, for audits and debug tooling. |
hooks:destroy() |
Removes every point and handler. kernel:shutdown() calls it. |
Handler contract: fn(context: { [any]: any }) -> boolean?. Return false to cancel; any other return, nil and
true included, continues the chain.
Hook points fired by the framework
Section titled “Hook points fired by the framework”The full table is in the hooks and bus topics reference. The shape:
| Point | Mode | Role |
|---|---|---|
Intent.<Channel> / Request.<Channel> |
fail-closed | Before every intent or request handler. Context { Name, Player, Session, Args, Seq? } (Seq only on predicted intents). |
Intent.BusPublish.<Topic> |
fail-closed | Client-to-server Bus publishes through BusBridge, after the whitelist and rate limit. |
Session.Start / Session.End |
observational | Synchronously before the matching Bus topic. Session.End adds ExitReason. See Sessions. |
Net.RateLimited / Net.FloodDetected / AntiExploit.MovementViolation |
fail-open | Policy attached after enforcement. |
Kit gates (Inventory.CanEquip, Craft.CanCraft and the rest above) |
fail-open | Optional game rules over already-guarded transport. |
Kit channels are ordinary intents (Intent.WK_Fire, Intent.SK_Cast, Intent.Checkpoint), so rules registered at a
lower priority number run before the kit’s own checks everywhere, without forking anything.
Performance
Section titled “Performance”A three-validator chain costs 148 ns per fire (6.6M ops/s), measured with one handler that changes the context, one
conditional veto and one pass-through, the usual intent shape. The whole intent security pipeline (rate limit, session
check, three validators) stays well under a microsecond per packet. Numbers and method are on
Benchmarks. The lazy sort means registration churn costs one table.sort on the next fire;
steady-state fires never sort.
Design notes
Section titled “Design notes”- Hooks ask permission and the Bus announces facts. A hook handler can cancel and change the context; a Bus subscriber can do neither. If nothing could ever veto, publish a topic instead.
- A handler crash on a fail-closed point logs a traceback by default. Specs build registries with
{ WarnOnError = false }to keep expected failures quiet. Production code should leave it on: a chain rejecting honest players because of a validator bug is exactly what the logs should show. The warning goes through Logger (scopeHookRegistry), so sinks see it and a spec can expect it withT.expectWarning. fireuses the caller’s context table without copying it. Do not fire one table from two threads at once, and treat the table after the fire as the validators’ output.