Appearance
Synced from the kernel README.
@typetorch/kernel
The small loader baked into the place. It is plain Luau (no compile step, no roblox-ts runtime), so it never shares a RuntimeLib with the payloads it loads. It boots a server, picks its branch, loads the branch's build (a payload uploaded by typetorch deploy) with LoadAsset, mounts it side by side with the running one and swaps to it without a restart; it holds the stable remotes, the /tt chat commands, the signed settings and the fleet reports. Changing the kernel itself needs a place update and new servers (typetorch kernel deploy, or tt kernel deploy).
Version 0.5.7, kernel API 1 (src/shared/Constants.luau: KERNEL_VERSION, KERNEL_API).
Where it fits
- CLI (
@typetorch/cli,typetorch/tt): typetorch.json"kernel"names the kernel folder (the template andtypetorch initusenode_modules/@typetorch/kernel).typetorch kernel deployruns this package'sscripts/check.luau, checks thatpackage.jsonversion=KERNEL_VERSIONandtypetorch.kernelApi=KERNEL_API, hashesplace.project.jsonandsrc/, requires a clean git checkout taggedv<version>(unless--allow-dirty/--allow-untagged; a packaged kernel is identified by version and hash), buildstypetorch.placeProject(place.project.json) with Rojo, stamps the identity (KernelVersion,KernelHash,KernelCommit) and the trust roots (KeyAssetId,FallbackPublicKey,BootstrapHeads) onServerScriptService.TypeTorchKernel, adds the backup build (ServerStorage.TypeTorchBackup), and patches only the kernel slots of the live place (or replaces the whole place with--replace-place, test places only). The CLI also writes what the kernel reads: deploy messages andheads/deploymentsin the DataStore, the signed settings (typetorch settings,access push,backend setup,experiment), signed pins (typetorch pin). - Framework (
@typetorch/framework): a payload'sServer.boot.boot(kernel)/Client.boot.boot(kernel)get the generation API below and return a stop function (0.2.2 passes it what replaces the generation). The typed view isServerKernel/ClientKernelin the framework'ssrc/kernel.ts; the framework'sTypeTorchAPI, networking, dev menu, analytics and devtools are built on it and feature-detect the additive methods. A payload declares the API it needs with theKernelApiattribute; a kernel with a lowerKERNEL_APIrefuses it. - Studio plugin (
typetorch/studio-plugin): setsLiveReloadonServerStorage.TypeTorchDevwhile it syncs Rojo into a playtest (see "Live reload in a Studio playtest" below). - Backend (
typetorch/backend): receives heartbeats, deploy reports, alerts, error reports, debug bundles and live debug streams at the signed settings'backendURL, and drives remote debug and the live map.
Guides: typetorch/docs (runtime API, branches and channels, prod signing, settings, fleet and alerts, cross-server messages, loading screens, Studio testing).
Layout
src/server/→ServerScriptService.TypeTorchKernel:Kernel(boot, branch choice, LoadAsset + side-by-side mounting, swaps with soft + hard stop, automatic rollback,/tt),Api(the server generation API),Registry(in-game DataStore / MemoryStore heads),Settings(the signed settings),Signing(Ed25519 rules),Access(dev access),Transport(stable remotes),Logs,Fleet(the fleet API sender),Fallback,Health,Messaging,Budget,Errors,RemoteDebug,LiveDebug,Bundle,vendor/andEntry(disabled template, cloned per generation). A place project that maps these files one by one must mapFleet(0.3.2),Fallback(0.3.6),Health(0.3.7) andMessaging(0.3.8),BudgetandErrors(0.4.0),RemoteDebug(0.5.0),Bundle(0.5.3) andLiveDebug(0.5.7) too; without them the kernel boots without that part and says so.src/shared/→ReplicatedStorage.TypeTorchKernelShared:Constants,ClientApi(the client generation API),ClientEntrytemplate.src/client/→ReplicatedFirst.TypeTorchKernelClient: the holding screen, followsActiveGeneration, swaps client generations.place.project.json: the whole place (kernel + baseplate + spawn, HTTP on,LoadStringEnabledoff).rojo build place.project.json -o build/place.rbxlbuilds it locally;typetorch kernel deploybuilds and publishes it.src/server/vendor/ed25519/: Ed25519 verify and SHA-512/SHA-256 from rbx-cryptography 3.1.4 (MIT, daily3014), see itsLICENSE.- The npm package ships
src,scripts/check.luau,place.project.json,rokit.toml,README.mdandLICENSE(no tests or smoke vectors).
The generation API
Both sides hand each generation a table with kernelApi, kernelVersion, artifact (the full identity), generation, branch, channel (the rules, kept for older frameworks), rules, branchChannel, serverType and start (how it started: boot or swap, reason, previous artifact, timings), plus ready(stop) / failed(err). Methods use : (roblox-ts calls interface methods that way). Details of each are in the sections below and in src/server/Api.luau / src/shared/ClientApi.luau.
- Server (
Api.luau;kernelBuildtoo):persist,isDev,devInfo,onMessage,send,broadcast,sendUnreliable,broadcastUnreliable,status,logs,branches,reload,rollback,switchBranch,artifacts,pinArtifact,newServer,pinned,onPending,onDevChanged,requestReload,experiment,unpin,keys,reportError,onClose,fleetStatus,onDeployReport,deployReports,accessCode,settings,settingsStatus,onSettingsChanged,messagingSubscribe,messagingPublish,messagingStatus,onRollCall,runDetached,setAnalyticsId,budgetCount,budget,onRemoteDebug,debugDeclare,onDebugCommand,onDebugColumn,onDebugStream,debugColumnsChanged. - Client (
ClientApi.luau):persist,onMessage,send,sendUnreliable,devStatus,onKernelEvent,logs,pinned,onPending,onDevChanged,requestSwitch,onResync.
ReplicatedStorage.TypeTorch carries the live server state for clients (ActiveGeneration, Channel (the rules), BranchChannel, Hold, HoldReason, ...).
/tt chat commands
For devs, and the way in when a generation is broken: /tt status | branch <name> | new <branch> [assetId] | pin <assetId> | unpin | reload | rollback | bundle | dev (dev opens the framework's dev menu). Owner-only rules are under "Owners and owner switches" below.
What it does, by version
- Deployment history and pins (0.2.0): every deploy message is recorded in the DataStore key
deployments;api:artifacts()lists it,api:pinArtifact(player, assetId)holds a server on a known artifact until its branch gets a newer deploy, andapi:newServer(player, branch, assetId?)opens a reserved server pinned to one. - Hooks for game code (0.2.2), used by the framework's
TypeTorchAPI:api.start(how the generation started: boot or swap, reason, previous artifact and branch, timings), stop info{reason, branch, next},api:onPending(a swap is coming, with an ETA; also broadcast to clients),api:onDevChanged,api:pinned()andapi:requestReload(player)(owners). Clients get the full artifact identity, the server type and their ownstart. - A/B experiments and rollouts (0.2.3):
api:pinArtifact(player, assetId, { experiment = true })lets an owner run ANY known artifact (dev channel too) on a public server, which stays "prod" (read-only devtools); it holds until the next deploy of the branch,api:unpin(player)//tt unpin, or the server closing.api:experiment()andstatus().experimentreport it. Remote pins arrive on topicTypeTorch/pin({j?, pct?, a, b, by, t, unpin?}, by JobId list or byjobBucket(JobId) < pct), and deploy messages may carryro(1-99): only servers whose bucket (djb2 of the JobId, mod 100) is below it swap; the others keep their artifact, and new servers boot the head. On dev servers both topics are unsigned; prod servers need them signed (below). - Signed prod deploys (0.3.0): prod servers (every public server, and private/reserved servers on a prod branch) take a deploy message, a stored head or a remote pin only with a valid Ed25519 signature (
sigfrom a key in the group-owned key asset'sPublicKeysminusRevokedKeys;sigFfrom the bakedFallbackPublicKeyuntil the key asset has loaded), the payloadChannelattribute exactly"prod", and a seq newer than the applied one. Payloads there may hold only Folders and ModuleScripts. The only unsigned head a prod server takes is the one inBootstrapHeads(exact branch, asset and seq). A re-signed head (r = "resign", same asset) moves the head without a swap. Pins there must be signed too (dev menu pins are refused: usetypetorch pin); a signed experiment may run any channel. Boot fail-safe: when no verified or bootstrap head loads at boot, the server boots the newest stored head that passes the prod checks, flaggedstatus().unverified(updates stay strictly verified). Dev servers stay unsigned.KeyAssetId,FallbackPublicKeyandBootstrapHeadsare attributes onServerScriptService.TypeTorchKernel, stamped bytypetorch kernel deployand read once at boot.api:keys()shows the trust state;api:artifacts()andstatus()carryverified = {main, fallback}. Setup and rules: Prod signing. - Studio local payload (0.3.1): in Studio only, when
ServerStorage.TypeTorchDev.Payload(a Model shaped like an uploaded payload:Server,Shared,Client,include) exists, the kernel mounts a fresh clone of it instead of calling LoadAsset, with the same mount checks (only Folders and ModuleScripts; dev channel). The artifact id islocal-<HHMMSS UTC>, the branch isTypeTorchDev.Branchordev, andstatus().localPayloadistrue. Such a session never follows the branch head (deploy messages, the poll); Reload remounts a fresh clone, and pins or branch switches still load uploaded artifacts. Outside Studio the folder is ignored. The template'sstudio.project.jsonsyncs it with Rojo (see the template README, "Testing in Studio"). - Live reload in a Studio playtest (0.5.4): while
ServerStorage.TypeTorchDevhas the attributeLiveReload = true(the TypeTorch Studio plugin sets it while it syncs Rojo into the playtest's server), a change to the local payload (a script'sSource, a name, an instance added or removed) reloads a fresh clone of it 0.4 s after the last change, as Reload does. Changes made while the attribute is off are dropped.lune run scripts/smoke-kernel.luau --studio-localcovers it. 0.5.5: the payload is watched through each script'sChanged(property name) signal;GetPropertyChangedSignal("Source")needs the PluginOrOpenCloud capability a game Script lacks, so 0.5.4 warned "live reload is off" in every playtest. - Devtools reach devs only (0.5.6): when the payload's framework has
devtools/on-demand, the client tree is mounted withoutinclude/node_modules/@typetorch/framework/out/devtools, and both trees get theDevtoolsOnDemandattribute; the framework hands each dev a copy. Older frameworks keep the folder (scripts/test-devtools-strip.luau). - Empty payloads are refused (0.5.6): a payload (local or uploaded) whose
ServerorClienthas nobootModuleScript fails with one line ("... has no Server.boot"; local: at load, uploaded: at mount) before the running generation is stopped, so a live reload or Reload keeps the old one. It used to start, wait 6 s for ready and print Roblox's "infinite yield" warning. The usual cause is Rojo serving an empty tree afterout/was deleted and recreated under it (typetorch serve restart rojo; serve 0.13.1+ restarts Rojo by itself). - No false health alerts during swaps (0.5.7): the fleet health watch (every 5 s) skips its tick while a swap or a last-known-good restore holds the swap lock (no generation runs for a moment then), and a worse state (degraded, failed, unverified) alerts only after it held 2 ticks in a row (
HEALTH_ALERT_TICKS). Before, a tick that landed in a deploy's swap gap, a branch switch or a slow boot's mount sent a criticalhealth_failed("health ok -> failed") that nothing cleared. A server where nothing can run still alertshealth_failed(critical), about 5 s later (Fleet.healthWatch,scripts/test-fleet.luau; smoke--fleet: a deploy whose build takes 1 s to start). - Live debug (0.5.7, plans/34;
src/server/LiveDebug.luau, optional): the game's own fleet columns, read-only commands and streams for the backend explorer, plus a live player map. Additive API (debugDeclaredoubles as the feature test; left out when the place doesn't mapLiveDebug.luau):api:debugDeclare({ columns = { { name, label?, type? } }, commands = { { name, label?, description? } }, streams = { { name, hz, kind?, label? } } })replaces the generation's declarations (names^[a-z][a-z0-9_]{0,31}$; at most 16 columns, 32 commands, 8 streams; labels 40 characters, descriptions 200;type"string" | "number" | "bool";kind"points" | "json";hzclamped to 0.2..4; bad entries dropped and counted) and returns{ ok, hash?, columns, commands, streams, dropped }.api:onDebugCommand(fn(name, args, caller)),api:onDebugColumn(fn(name)),api:onDebugStream(fn(name))install the generation's handlers;api:debugColumnsChanged()asks for a heartbeat.- Heartbeats (Fleet.luau) carry
xh(a 16-hex hash of the declarations) whenever something is declared,xd(the wire form; notx, the A/B experiment flag:{ c = { { n, l?, t? } }, cmd = { { n, l?, d? } }, st = { { n, hz, k?, l? } } }) on the first one, after a change, for a new backend and after a reply'sxq = 1, andcv={ <column> = string (64 characters) | number | bool | null }, each getter on a kernel thread under pcall (all within 0.25 s; failing, yielding or odd values are null). A declaration change,xqanddebugColumnsChangedsend a coalesced heartbeat, at most one perFLEET_COLUMNS_GAP(10 s). The closing request carries none of them. - Commands: op
game.<name>from the explorer runsfn(name, args, caller)only when the running generation declaredname(else refused like an op off the allow-list), with remote debug's rate limit, owner check, deadline, 200 KB cap and secret scrubbing; args at most 2 KB of JSON. Read-only is the game's promise. - Streams: while the remote debug poll runs, the built-in
positions({ players = { { u, n, x?, y?, z?, yaw?, hp?, st } } }: the HumanoidRootPart to 1 decimal, yaw 0 = facing -Z, Humanoid health, st "alive" | "dead" | "none") at 2 Hz and each declared stream (game.<name>) at its rate are sampled on a kernel thread (a sampler gets 0.1 s) and posted every second toPOST <url>/v1/fleet/streams { j, frames = { { t, s } } }: a value at most 16 KB of JSON (else dropped, counted), a body at most 64 KB (the oldest frames go), custom values scrubbed of secrets. They stop when a reply says neitherwatchnorlm, with the poll, or after 3 failed posts in a row (a 401/403/404 holds them off 300 s). - Live map mode: a wake
{ k = "lm" }onTypeTorch/deploy(every server; one acted on per 10 s; unsigned, so the first post asks the backend) or a heartbeat reply'slm = 1postspositionsonly, one frame everyLM_INTERVAL(3 s), while the replies saylm(orwatchwith no poll running, which also wakes the poll), until 60 s pass without one or a reply says neither. Older kernels ignore the wake. - Counted in the budget view as HTTP
debug;status().liveDebughas the counters (never a value, the URL or the key). Studio and servers without a backend never post.
- Bad deploys are safe (0.3.2):
- Health window: a failed
onStart(the framework reports it withapi:reportError) or 3 errors from the new generation's own scripts within 30 s of ready roll the server back. Errors from outside the generation never count. 0.3.7 (src/server/Health.luau): each build carries its own thresholds, typetorch.json"health"stamped bytypetorch buildon the payload root asHealthErrors(1-100),HealthWindow(5-300 s) andHealthRollback(false: count errors, never roll back; the server reportsdegraded). Missing or out-of-bounds values fall back to 3 / 30 s / on (listed instatus().health.invalid);status().healthreportslimit,window,rollbackandsource. A place project without the module boots with no health window and warns. - Last known good: this server's history, then the branch's deployments (prod: only verified ones, or heads this server already ran), skipping artifacts that failed here. Also at boot when the head fails to start.
- Fleet status and deploy reports:
api:fleetStatus()(this server's heartbeat; 0.5.6: withpv, the place version, which the backend uses to read Roblox's own logs for the server),api:onDeployReport(fn)(one report per deploy outcome, the last 20 replayed) andapi:deployReports(); the kernel writes nothing to MemoryStore for them. With the signed settings'fleet={url, token}(0.3.8; the ConfigService keyTypeTorchFleetbefore) the kernel also posts heartbeats (every 30 s and on changes), reports, alerts and a closing notice to that fleet API itself, live and also while the generation is broken (src/server/Fleet.luau; the token is never logged). 0.3.9: when the API can't be reached (a restarting or dead quick tunnel:NetFail; a stale trycloudflare URL:DnsResolve/ 530) every retry is jittered (+-25%), and after a request's retries all failed the sender backs off (30 s doubling to 300 s); a 401/403/404 holds it off about 5 minutes; one success resets it. One warning line a minute at most, with the reason, and one "heartbeats work again" line after an outage;status().fleetaddsfailures(failed requests in a row) andretryIn. - Boot budget: a new server runs a playable generation within 15 s of start in every path (about 2 s normally): the boot reads run in parallel behind one 3 s gate, the key asset gets 3 s, and boot attempts get short load and ready caps; a head that runs out of boot time is retried in the background and swapped to when it works.
- Client generation reports with one retry,
api:onClose(fn)from the kernel's BindToClose, and every swap game code starts runs on a kernel thread.status()addshealth,failed,reports,fleetandclients. - Messages across a swap: with a
ProtocolHashattribute on the payload, client events of the previous artifact of the same hash reach the new generation; the resync names the dropped artifact and reaches only that client generation (onResync).
- Health window: a failed
- Owners and owner switches (0.3.4; replaces the 0.3.3 owner override): two roles, owner (the experience creator, the owning group's owner, members with role "owner") and dev; a member with the old role "admin" is a dev, with a warning once per server. Owner-only: A/B pins, pins and unpins on public servers, rollbacks on servers with prod rules (see "Channel and rules" below),
requestReload. The dev menu's Switch and Load here are ordinary switches and pins on every server: an owner switches a public server to any branch for its lifetime (never stored, so new servers still boot the signed prod head; a dev branch follows dev rules there; back to prod = Switch on the prod row, its verified head) and pins any known build on public and prod servers (the owner's own pin: any channel, modules only). Only from the owner's own client (kernel transport__tt/switch,ClientApi:requestSwitch({branch | assetId}), one request per player per 2 s) or/tt branch//tt pin; the server API has no owner path. Private and reserved servers keep the stored switch for devs. Reload and Rollback always work.status().switched = {by, name, at, branch, artifact?}, the fleet heartbeat'sbis the branch, and each switch or load posts an info alert (branch_switch,build_load) and is printed and logged. - Channel and rules (0.3.9): two separate things. The CHANNEL is what the branch is:
prodfor the signed settings'defaultBranchor a branch the settings'channelsconfigure prod,devfor every other branch. It is what a server reports:status().channel,/tt status, the fleet heartbeatc, game message tags, the dev menu. The RULES are what it enforces, exactly as before 0.3.9 (where they were reported as the "effective channel"):prodon every public server and on a server whose branch or running build is prod-channel by trusted sources: read-only devtools for non-owners, owner-only rollbacks (signatures stayrequiresSignatures: public servers and trusted-prod branches).status().rules;/tt statussaysbranch dev (dev, prod rules)for a dev branch on a public server. The payload API keepschannel= the rules (older frameworks gate their devtools on it, and games split data store names byTypeTorch.channel) and addsrulesandbranchChannel;ReplicatedStorage.TypeTorchhasChannel(the rules) andBranchChannel. /tt rollback(0.3.9) on a server with no earlier generation (a fresh server that booted straight into a bad build) takes the branch's previous head:heads.<branch>.previn the DataStore copy (the last 3 heads, newest first, kept by the CLI 0.8.1+ and by the kernel's own durable writes; MemoryStore never carries it), then the branch's deployment history (records written before 0.3.9 have noprev). A server that requires signatures takes only one that verifies like a current head (signed for prod, or the exact BootstrapHeads match; Channel "prod" at mount). Still a server-local swap (server_rollback, logged withsource = "branch", appliedSeq stays: it holds until the branch's next deploy). Nothing found: "no earlier build on this server or in prod's previous heads ... To roll the whole branch back: typetorch rollback prod".- Durable heads (0.3.5): the CLI writes the DataStore
TypeTorchkeyheads(anddeployments) after every deploy message, so a deploy made while no server of that branch ran is no longer lost. The boot waits for the DataStore copy as well as MemoryStore (inside the 3 s boot gate) and boots the newer head; running servers read that copy about once a minute (HEADS_DURABLE_SECONDS= 55, one GetAsync per server per 60 s sync tick, out of a server's 60 + 10 x players DataStore reads a minute) and follow a newer head like a polled one (prod: the same signature rules). When the DataStore copy is ahead of MemoryStore, the server writes MemoryStore back (one UpdateAsync with the samereplacesrule, so only the first server's write changes anything). Smoke:--durable. - Never an empty server (0.3.6; the user: "IN NO POSSIBLE WAY players should load into an empty baseplate";
src/server/Fallback.luau):- Hold: from the kernel's first line until a generation is ready, characters don't spawn (
Players.CharacterAutoLoadsoff; on release it is back on and waiting players getLoadCharacter), and the kernel client covers the place with its own holding screen (opaque, "Starting...", a progress bar, a UIScale pop, no emojis), built from theHoldattribute onReplicatedFirst.TypeTorchKernelClientbefore anything else waits. Measured: the server holds 2 ms after the kernel starts (before any player can join); the client's screen is up 0 ms after its script starts. A swap of a running generation never holds. Games that spawn characters themselves set thePlayersattributeTypeTorchHoldCharacters = false(or keepCharacterAutoLoadsoff in the place): the kernel then never touches characters (the screen still shows while nothing runs). - 0.3.9, "Starting..." stayed up while the game ran: ReplicatedFirst is replicated once, when a player joins, so the script's
Holdis only the join snapshot; the 0.3.6-0.3.8 client read it first and kept "Starting..." for good when the player joined during the boot hold (on a test place the first player always does: their join starts the server). The client now followsReplicatedStorage.TypeTorch'sHold/HoldReason(live; set before that folder is parented) once it has it. The hold ends the moment a generation runs (before the dev checks, which yield on web calls); the watchdog releases a "start" hold it still finds on while a generation runs (3 s, warned); the dead man's moves stop once the kernel's own watchdog runs. Diagnostics: one line per hold change in the kernel's log ring (the dev menu's Logs, Logs > Upload),[TypeTorch] hold start: <reason>/hold released: generation X running, and on the client[TypeTorch] screen up (Starting...): server hold start: <reason>/screen down: generation X running;HoldReasonnext toHold(server) and next toHolding(client). Fail-safe: when the client's own generation runs, the server has anActiveGenerationand the hold still says "start" after 5 s, the client drops the screen and warns with the state it saw ("move" holds are never dropped). The screen's DisplayOrder is 2147483000 (HOLD_DISPLAY_ORDER): above every game UI, under the framework's dev menu (+100). - Fallbacks when the head and the last-known-good chain ran nothing, in this order: (4) a build the branch's other servers run healthy for 30 s+ (a kernel roll call: an ask
{rc = 1}onTypeTorch/deploy, so no extra permanent subscription; replies onTypeTorch/peers/<JobId>; the most common build wins; prod servers take it only when this kernel trusts it: a valid signature, a deployment entry that verifies, orBootstrapHeads); (3) the backup buildtypetorch kernel deploybakes into the place,ServerStorage.TypeTorchBackup, mounted as a fresh clone with the payload checks (Channelprod, modules only), flaggedstatus().backup, fleetbackup = trueandh = "backup", alertbackup_build; (5) background retries of the head, the last known good and peers (15 s, 30 s, then every 60 s) until the head runs, each a normal swap; (2) at the boot budget, players (and new joiners) move to a healthy server of the branch (roll call), else a fresh one (private/reserved: a new reserved server on the branch), with a bounce count in the teleport data; the 3rd bounce is kicked ("Servers are restarting. Please rejoin in a minute."), logged and alerted (bounce_kick); alertboot_failed_teleport. Boot timings (simulated): peers 5.0 s, asset outage to the backup 4.6 s (10.0 s when every LoadAsset hangs), moves at 15.1 s. - Mid-swap failure: when a swap stopped the old generation and nothing replaced it,
ActiveGenerationis cleared (clients stop the dead client code) and the hold arms again until the chain above runs something. BindToClose in the middle of a swap runs the starting (else the stopped) generation'sonClose. If the kernel itself errors before its boot finishes, the held players are still moved 5 s past the budget. - Clients: a client generation that failed after its own retry gets its client tree re-sent once (
PlayerGui.TypeTorchResend,__tt/resend); if that fails too, the player moves to a healthy server of the branch (or rejoins).status().clientsand the fleet statusclientscountresentandmoved. status().fallback={hold, backup, peers, chain, recovery, moving, clients}.
- Hold: from the kernel's first line until a generation is ready, characters don't spawn (
- Security audit follow-ups (0.3.6, plans/18): the dev lists come from
typetorch access push(0.3.8: the signed settings'access; 0.3.6-0.3.7: the ConfigService keyTypeTorchAccess); the storedheadskey holds at most 32 branches, and prod servers record another branch's deploy only when they know the branch or the message is signed;place.project.jsonsetsLoadStringEnabledfalse (kernel deploy --loadstringturns it on for a test place). - The signed settings record (0.3.8,
src/server/Settings.luau; plans/20): ONE DataStore entry,TypeTorch/settings={ v = 1, seq, at, body, sig, sigF }, replaces every ConfigService key.bodyis JSON text (defaultBranch,channels,access,backend,fleet,analytics,game);sig/sigFare both prod keys' Ed25519 signatures oftt1settings \n seq \n at \n body(Signing.settingsCanonical; the strict rule of signed deploys). Read at boot (inside the 3 s gate), every 55 s from the sync tick and right after a ping ({"k":"settings","s":seq}onTypeTorch/deploy); taken only when it verifies and its seq is newer; a missing, unsigned, invalid or older copy keeps the last good one.api:settings()(a copy, server only: it holds tokens),api:onSettingsChanged(fn),status().settings(state, seq, age, verifiedBy, fields, refused). Written only by the CLI (typetorch settings,backend setup,access push). The StudioRegistry/BootAssetIdoverrides and the ConfigService log quieting are gone. - Game messaging (0.3.8,
src/server/Messaging.luau; plans/19 item 4): game topics ride ONE MessagingService topic,TypeTorch/game, subscribed on the first listener and held for the server's life (a swap never subscribes again).api:messagingSubscribe(topic, fn)(fn(data, meta), dropped with the generation),api:messagingPublish(topic, data, {to})(checks topic, JSON data and the 1 KiB limit counted on the JSON-escaped envelope at once, then queues: a per-server bucket of 150 + 60 x players a minute, the universe's rate on the topic (every server sees every message; at 60 a minute publishes wait), retries after 1, 3, 9 s),api:messagingStatus(),status().messaging. Envelopes carry the sender's JobId, branch, channel (0.3.9: what its branch is; before, the rules), server type and place version;to = "prod" | "branch"is filtered by the receivers. A message that arrives mid-swap is replayed to the next generation's first listener of its topic. Studio loops back and never touches MessagingService.api:onRollCall(fn)holds the framework's roll call ask topicTypeTorch/rollcallfor the running generation (same protocol as before). Subscriptions: deploy, pin, rekey, plus game and roll call once used (Roblox allows 20 + 8 x players per server). - Loading screens (0.3.8, plans/19 item 13): the kernel client sets
ClientReady = true(the first client generation runs),ClientGenerationandHolding(the holding screen's mode) onReplicatedFirst.TypeTorchKernelClient. The game'sReplicatedFirstattributesTypeTorchBootScreen = "<ScreenGui name>"(cloned at once, removed when the first client generation runs and the hold ends, at most 60 s) andTypeTorchKernelScreen = false(the game draws its own) are copied onto that script by the server before anyone joins; until then the kernel's "Starting..." stays off. Moves ("Reconnecting...") and later holds always show the kernel's screen. - Detached jobs (0.3.8):
api:runDetached(fn, done?)runsfn()on a kernel thread, so a generation's stop (and its hard stop) never cuts it off: for library calls that must not stop halfway (a ProfileStore load, save or release).done(ok, result)runs only while the calling generation still runs (a stopped generation's result is dropped: the job writes what must survive intopersist). At mostDETACHED_MAX(256) run at once (then it errors); one running pastDETACHED_SLOW(60 s) is logged once; errors go to the log with the generation's name and never count toward its health window;status().detached={ running, started, finished, failed, slow, max, oldest?, lastError? }. A job keeps the old generation's closures (and what they reference) alive until it ends. - One backend (0.4.0, plans/21 part C): the signed settings'
backend= { url, key, analytics? } is ONE URL and ONE key: heartbeats, reports, alerts and the closing notice to<url>/v1/fleet/*, error reports to<url>/v1/errors(and the framework's analytics to<url>/v1/ingest). The oldfleet= { url, token } is read only whilebackendis missing (status().fleet.source); hints nametypetorch backend setup. - Error reports (0.4.0, problem 19;
src/server/Errors.luau): server errors (ScriptContext.Error and LogService error lines, from every generation and the kernel; a script error and its log line count once) and client errors (the kernel client forwards its own on__tt/errors: at most 10 distinct messages per 10 s, the server takes one batch per 5 s per player) are templated on the server (every current player's, and for 2 minutes every leaving player's, Name, DisplayName and UserId as whole words become<player.name>,<player.display_name>,<player.user_id>;Generations.<artifact>#<n>becomes<gen>), fingerprinted (FNV-1a + djb2 of the template and the top stack line, 16 hex), counted per minute per kind, realm, branch and build with the pids of the players affected (the framework's analytics engine tells the kernel each pid:api:setAnalyticsId(player, pid); at most 10 per item), and posted in batches (<= 200 items, <= 400 KB JSON, gzip, at most 6 requests a minute, every 15 s while items wait). Bounded: 200 kinds, 600 waiting items, 500 errors looked at a second; drops counted. Backoff like Fleet's 0.3.9 (jittered retries, 30 s doubling to 300 s, 401/403/404 hold ~5 min, 429 waits its Retry-After, 413 halves the batch, 400 drops it). Studio counts without posting.status().errors= { enabled, host, kinds, waiting, seen, sent, requests, failed, rejected, dropped, failures, retryIn, lastOkAt, lastError, top (templates only) }. - Budget view (0.4.0, problem 25;
src/server/Budget.luau): every DataStore (read, write, list, remove; UpdateAsync is both), MemoryStore (units: UpdateAsync 2), HTTP (per label: fleet, errors, ingest, claude) and MessagingService (publish, subscribe) request TypeTorch makes, per minute (a sliding 60 s estimate) and per caller (kernel: counting proxies of the Registry's stores and explicit counts elsewhere;devtools,analytics,frameworkandgamethroughapi:budgetCount(caller, kind, op, n?); TypeTorch.messaging publishes count asgame), next to Roblox's documented limits for the player count (sources in Budget.luau: DataStore 60 + 40 x players per category, list 5 + 2 x players; MemoryStore 120 x players of the experience's 1000 + 120 x CCU units; HTTP 500; MessagingService 600 + 240 x players publishes, 240 subscribes), the DataStore budget left (GetRequestBudgetForRequestType, shared with the game) and memory (total, per developer tag, the Lua heap; the mounted generations and their modules).api:budget()is the full view (the dev menu's Server > Budget);status().budgetand every fleet heartbeat (bu) carry a compact summary the backend stores per server. - Server TPS (0.4.2,
src/server/Budget.luau):RunService.Heartbeatframes are counted into one-second buckets. Every fleet heartbeat carriespf={ a, m, p }: the average TPS and the slowest second since the previous heartbeat, andworkspace:GetRealPhysicsFPS()(0.1 steps, about 25 bytes; nil before the first second). Memory is not repeated: it stays inbu.mem={ t, h }(total MB, LuaHeap MB; the Lua VM heap when the tag can't be read).status().perf={ a, m, p, s }over the last 60 s (the dev menu's Server > Status). - Seq adoption (0.4.2; the 2026-10-09 rollback drill): a deploy, branch rollback or poll head whose artifact this server already runs (typically the build a local health rollback just restored) is adopted without a swap: the seq is applied and reported
swapped(d = 0). A deploy that arrives while a local rollback runs waits for it, then adopts instead of swapping the same build in again. Whenever the applied seq moves, aseqheartbeat is sent ahead of the queued reports, sodeploy --waitnever sees the report before the server's newq. - Remote debug (0.5.0, plans/25;
src/server/RemoteDebug.luau, optional): the backend explorer's/servers/<JobId>page reads this server like the dev menu, read-only. A Roblox server can't be called into, so it pulls: once the backend says the job is watched, the kernel long-polls the signed settings' backend (GET <url>/v1/fleet/commands?wait=8,X-TT-Job), runs each allow-listed op on a kernel thread (kernel ops: status, builds, budget, logs, players, player.logs over__tt/rdlogs, errors; framework ops throughapi:onRemoteDebug), owners only, at most 30 a minute, answers scrubbed of every secret the kernel knows, and posts them toPOST <url>/v1/fleet/results. It stops when the backend says "not watched" (no request at all while nobody watches). Counted in the budget view (HTTP,debug);status().remoteDebughas the counters (never a command, the URL or the key).- How it learns it is watched: a heartbeat reply
{ ok, rd = 1 }(up to one heartbeat, 30 s), or (0.5.1, instant wake) a wake message{ k = "rd", j = <JobId> }onTypeTorch/deploythat a backend withOPENCLOUD_API_KEYandTYPETORCH_UNIVERSE_IDpublishes through Open Cloud when the watch starts: the named server starts polling at once. No new subscription (the kernel's control topic already carries the settings ping and the peers' ask). The wake is unsigned and only does whatrd = 1does: the first poll asks the backend, which says whether the job is really watched, so a forged wake costs one poll. Another JobId's or a malformed wake is ignored, one perRD_WAKE_GAP(10) s at most, none without a backend in the settings; it never becomesstatus().lastMessage.status().remoteDebug.wakes/wakesIgnored/lastWakeAtcount them. Older kernels ignore the message (no branch).
- How it learns it is watched: a heartbeat reply
- Debug bundles (0.5.3;
src/server/Bundle.luau, optional): when this server fails, or shuts down after a failure, it posts what happened toPOST <url>/v1/fleet/bundle(the same backend and key as the fleet API) by itself, so the owner can see it without watching:{ v = 1, id, j, t, k, b, a, s, g, code, level, message, closing, failure, server, deploy, fallback, alerts, players, serverLog, playerLogs }. Triggers: every critical alert (deploy_failed, no_trusted_head, artifact_refused, kernel_api_mismatch, booted_unverified, health_rollback, lkg_exhausted, ...), a generation that fails to start (start_failed), Fallback moving players off the server (players_moved) and a dev's/tt bundle(manual). The trigger gets the bundle id at once (the alert carries it asd.bundle); the bundle goes ~3 s later with that id (triggers in between join it, listed infailure.triggers). At most one per trigger code per 60 s and 10 per server, plus one from BindToClose, which sends one ONLY when a failure fired in the last 10 minutes (or flushes the one still waiting), in parallel with the fleet and error closing requests, inside the close deadline. Contents: the server section (identity, generation, fleet status without the access code, budget, health, clients, errors), the deploy state (appliedSeq,history, the last 10 full swapreports,failing,failedHere,pending,wanted,busy,restoring, the branch'sheads, the last 10refusals),Fallback.status(), the last 10 alerts, the last 300 server log lines, the players here and those who left in the last 10 minutes (user id, name, join and leave time, ping, their client's start status) and player logs: the present clients' log rings over the remote-debug channel (__tt/rdlogs; at most 20, in parallel, 5 s), and for the others the error lines their client sent (kept 10 minutes after they leave). The JSON is cut to ~200 KB (player logs first, then the server log, then the deploy history;server.trimmedsays what went); the backend key and the settings' secrets are replaced with<key>. A 404 (an older backend) stops bundles for the server's lifetime; no answer, 429 and 5xx are retried.status().bundleshas the counters (never the URL or the key). Studio and servers without backend settings send nothing.
Development
- Tools (pinned in
rokit.toml: Lune 0.10.5, Rojo 7.7.0-rc.1):rokit install. - Syntax check:
npm run check(=lune run scripts/check.luau) compiles every.luaufile and exits non-zero on a parse error.npm publishruns it (prepublishOnly), andtypetorch kernel deployruns it before anything else. - Tests (Lune; there is no CI test job, run them before a release):
lune run scripts/test-ed25519.luau(RFC 8032 vectors, SHA-512/256, and the signing rules inscripts/test-signing.luau),lune run scripts/test-access.luau.lune run scripts/test-fleet.luau(0.3.9: the fleet sender's backoff, hold and log lines on a fake clock; 0.4.2: aseqheartbeat goes before queued reports).lune run scripts/test-errors.luau(0.4.0: templating, fingerprints, per-minute counts, limits, batches, the sender, client batches, the dedup),lune run scripts/test-budget.luau(0.4.0: proxies, limits, the sliding minute, callers, the summary; 0.4.2: TPS windows, the slowest second, physics FPS, the Lua heap fallback).lune run scripts/test-remote-debug.luau(0.5.0: allow-list, owners only, rate, secret scrub, plain data, the session, client logs; 0.5.1: the wake: this JobId within a tick, other JobIds and malformed ignored, the local rate, a forged wake costs one poll, no backend no poll),lune run scripts/test-live-debug.luau(0.5.7: declarations and the hash, heartbeats' xh / xd / cv / xq and the columns gap, failing and yielding getters,game.*routing, stream rates, batching and caps, the stop rules, live map mode and its lapse),lune run scripts/test-bundle.luau(0.5.3: triggers and the id, joining, the per-code and per-server limits, the close rule, parallel client logs and their timeout, the left players' lines, trimming, the key scrub, retries and the 404 stop, the body against the contract),lune run scripts/test-devtools-strip.luau(0.5.6: the devtools folder left off the shared client tree).lune run scripts/smoke-kernel.luauboots the real server kernel with stubbed services (vectors inscripts/smoke-settings.jsonandscripts/smoke-signatures.json;--verboseprints more). Modes:--fallback,--dev,--vip,--bootstrap,--bootstrap-only,--blind,--empty,--studio-local(0.5.4: live reload too),--health,--health-prod,--lkg-boot,--client,--fleet,--switch,--durable, 0.3.6's--hold,--hold-optout,--peers,--peers-untrusted,--backup,--recover,--teleport,--bounce,--mid-swap,--client-fail,--kernel-crash,--heads-cap,--access, 0.3.7's--health-configand--health-missing, 0.3.8's--messaging,--messaging-studio,--messaging-missing,--client-boot,--client-own,--settings,--settings-missingand--detached, 0.3.9's--rollback-prev, 0.4.0's--errors(settings.backend, heartbeats withbu, a server error and a client batch posted templated with the pid, the budget view), 0.4.2's--perf(heartbeatpffrom Heartbeat frames, its size,bu.mem,status().perf) and--adopt(a deploy's heartbeat before its report; the rollback drill: a local health rollback, then the branch rollback naming the same build, adopted with no swap, also when it arrives mid rollback and when only the poll finds it), 0.5.0's--remote-debug(the heartbeat'srdstarts the poll, kernel and framework ops, refusals, client logs, no secret in an answer; 0.5.1: a wake onTypeTorch/deploystarts it with quiet heartbeats; 0.5.7: live debug through the real kernel: the declarations on heartbeats, agame.*command, streams while watched, thelmwake), 0.5.3's--bundleand--bundle-clean(a failed deploy's alert withd.bundle, then the bundle with that id, players, client logs and the server log;/tt bundle; a closing bundle after the failure, none after a clean close), and--boot-budget, which runs 26 boot scenarios (0.5.2 addsdeploy-during-boot: a deploy that takes the swap lock mid boot;--boot-scenario <name>runs one) with simulated slow or failing dependencies and checks each one runs a generation within 15 s, or moves the waiting player at the budget when nothing can run.
Releasing
Pushing a version tag publishes; nothing else does (.github/workflows/publish.yml, the repo's only workflow).
- Bump the version in BOTH
package.jsonandsrc/shared/Constants.luau(KERNEL_VERSION); bumpKERNEL_APIandpackage.jsontypetorch.kernelApitogether, and only on a breaking change to the generation contract (updateServerKernel/ClientKernelin the framework with it). - Run the check and the tests above, merge to
main. - Tag and push:
npm version <x.y.z> -m "release %s" && git push origin main --follow-tags(or tagv<x.y.z>on the bumped commit and push the tag).
The workflow installs Lune, refuses a tag that doesn't match package.json or a version already on npm, runs npm publish --provenance --access public (which runs the syntax check first) and creates a GitHub release with generated notes. A prerelease version (x.y.z-next.0) goes to the next dist-tag and a prerelease GitHub release; anything else to latest. Auth is npm trusted publishing (OIDC, GitHub environment npm), no npm token needed once it is set up on npmjs.com (@typetorch/kernel > Settings > Trusted publishing: this repo, workflow publish.yml, environment npm); the workflow still passes an NPM_TOKEN secret, used only if one is set, until then.
The v<version> tag is also what typetorch kernel deploy wants on a git checkout of the kernel.