Skip to content

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.

A registry is a map of points, and a point is a list of handlers:

  • Each handler is { Fn, Priority, Order }. Order is 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 fire sorts 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 under xpcall with debug.traceback and receives the same context table:
    • returns nil or anything but false: the chain continues;
    • returns false: the chain stops and fire returns false, context;
    • throws: a [HookRegistry] hook "<name>" handler errored: warning is logged (unless WarnOnError = false) and, unless the point is FailOpen, the chain stops and fire returns false, context, traceback.
  • 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.

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.

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:

src/Shared/Game/Channels.luau
--!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:

src/Server/Game/Services/ItemService.luau
--!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 = kernel
end
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()
end
end
return ItemService

Firing 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
return
end
-- Validators may have added to the context, for example Context.Tax from an economy rule
applyTrade(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 > 0
end)
Off() -- handlerCount("Intent.Jump") drops by one

The xpcall around every handler turns crashes into rejections:

  1. A validator exists because the action is dangerous without it.
  2. A validator that crashed has not validated anything.
  3. 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.

Some points must not reject on error, and they opt out explicitly:

kernel.Hooks:definePoint("Telemetry.Event", { FailOpen = true }) -- logging must never block gameplay

The 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.

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.

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.

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.

  • 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 (scope HookRegistry), so sinks see it and a spec can expect it with T.expectWarning.
  • fire uses 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.

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