Skip to content

Synced from the kernel README.

TypeTorch logo

@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 and typetorch init use node_modules/@typetorch/kernel). typetorch kernel deploy runs this package's scripts/check.luau, checks that package.json version = KERNEL_VERSION and typetorch.kernelApi = KERNEL_API, hashes place.project.json and src/, requires a clean git checkout tagged v<version> (unless --allow-dirty / --allow-untagged; a packaged kernel is identified by version and hash), builds typetorch.placeProject (place.project.json) with Rojo, stamps the identity (KernelVersion, KernelHash, KernelCommit) and the trust roots (KeyAssetId, FallbackPublicKey, BootstrapHeads) on ServerScriptService.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 and heads / deployments in the DataStore, the signed settings (typetorch settings, access push, backend setup, experiment), signed pins (typetorch pin).
  • Framework (@typetorch/framework): a payload's Server.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 is ServerKernel / ClientKernel in the framework's src/kernel.ts; the framework's TypeTorch API, networking, dev menu, analytics and devtools are built on it and feature-detect the additive methods. A payload declares the API it needs with the KernelApi attribute; a kernel with a lower KERNEL_API refuses it.
  • Studio plugin (typetorch/studio-plugin): sets LiveReload on ServerStorage.TypeTorchDev while 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' backend URL, 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/ and Entry (disabled template, cloned per generation). A place project that maps these files one by one must map Fleet (0.3.2), Fallback (0.3.6), Health (0.3.7) and Messaging (0.3.8), Budget and Errors (0.4.0), RemoteDebug (0.5.0), Bundle (0.5.3) and LiveDebug (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), ClientEntry template.
  • src/client/ → ReplicatedFirst.TypeTorchKernelClient: the holding screen, follows ActiveGeneration, swaps client generations.
  • place.project.json: the whole place (kernel + baseplate + spawn, HTTP on, LoadStringEnabled off). rojo build place.project.json -o build/place.rbxl builds it locally; typetorch kernel deploy builds and publishes it.
  • src/server/vendor/ed25519/: Ed25519 verify and SHA-512/SHA-256 from rbx-cryptography 3.1.4 (MIT, daily3014), see its LICENSE.
  • The npm package ships src, scripts/check.luau, place.project.json, rokit.toml, README.md and LICENSE (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; kernelBuild too): 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, and api:newServer(player, branch, assetId?) opens a reserved server pinned to one.
  • Hooks for game code (0.2.2), used by the framework's TypeTorch API: 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() and api:requestReload(player) (owners). Clients get the full artifact identity, the server type and their own start.
  • 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() and status().experiment report it. Remote pins arrive on topic TypeTorch/pin ({j?, pct?, a, b, by, t, unpin?}, by JobId list or by jobBucket(JobId) < pct), and deploy messages may carry ro (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 (sig from a key in the group-owned key asset's PublicKeys minus RevokedKeys; sigF from the baked FallbackPublicKey until the key asset has loaded), the payload Channel attribute 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 in BootstrapHeads (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: use typetorch 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, flagged status().unverified (updates stay strictly verified). Dev servers stay unsigned. KeyAssetId, FallbackPublicKey and BootstrapHeads are attributes on ServerScriptService.TypeTorchKernel, stamped by typetorch kernel deploy and read once at boot. api:keys() shows the trust state; api:artifacts() and status() carry verified = {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 is local-<HHMMSS UTC>, the branch is TypeTorchDev.Branch or dev, and status().localPayload is true. 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's studio.project.json syncs it with Rojo (see the template README, "Testing in Studio").
  • Live reload in a Studio playtest (0.5.4): while ServerStorage.TypeTorchDev has the attribute LiveReload = true (the TypeTorch Studio plugin sets it while it syncs Rojo into the playtest's server), a change to the local payload (a script's Source, 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-local covers it. 0.5.5: the payload is watched through each script's Changed (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 without include/node_modules/@typetorch/framework/out/devtools, and both trees get the DevtoolsOnDemand attribute; 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 Server or Client has no boot ModuleScript 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 after out/ 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 critical health_failed ("health ok -> failed") that nothing cleared. A server where nothing can run still alerts health_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 (debugDeclare doubles as the feature test; left out when the place doesn't map LiveDebug.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"; hz clamped 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; not x, 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's xq = 1, and cv = { <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, xq and debugColumnsChanged send a coalesced heartbeat, at most one per FLEET_COLUMNS_GAP (10 s). The closing request carries none of them.
    • Commands: op game.<name> from the explorer runs fn(name, args, caller) only when the running generation declared name (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 to POST <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 neither watch nor lm, 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" } on TypeTorch/deploy (every server; one acted on per 10 s; unsigned, so the first post asks the backend) or a heartbeat reply's lm = 1 posts positions only, one frame every LM_INTERVAL (3 s), while the replies say lm (or watch with 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().liveDebug has 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 with api: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 by typetorch build on the payload root as HealthErrors (1-100), HealthWindow (5-300 s) and HealthRollback (false: count errors, never roll back; the server reports degraded). Missing or out-of-bounds values fall back to 3 / 30 s / on (listed in status().health.invalid); status().health reports limit, window, rollback and source. 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: with pv, 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) and api:deployReports(); the kernel writes nothing to MemoryStore for them. With the signed settings' fleet = {url, token} (0.3.8; the ConfigService key TypeTorchFleet before) 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().fleet adds failures (failed requests in a row) and retryIn.
    • 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() adds health, failed, reports, fleet and clients.
    • Messages across a swap: with a ProtocolHash attribute 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).
  • 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's b is 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: prod for the signed settings' defaultBranch or a branch the settings' channels configure prod, dev for every other branch. It is what a server reports: status().channel, /tt status, the fleet heartbeat c, 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"): prod on 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 stay requiresSignatures: public servers and trusted-prod branches). status().rules; /tt status says branch dev (dev, prod rules) for a dev branch on a public server. The payload API keeps channel = the rules (older frameworks gate their devtools on it, and games split data store names by TypeTorch.channel) and adds rules and branchChannel; ReplicatedStorage.TypeTorch has Channel (the rules) and BranchChannel.
  • /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>.prev in 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 no prev). 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 with source = "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 TypeTorch key heads (and deployments) 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 same replaces rule, 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.CharacterAutoLoads off; on release it is back on and waiting players get LoadCharacter), and the kernel client covers the place with its own holding screen (opaque, "Starting...", a progress bar, a UIScale pop, no emojis), built from the Hold attribute on ReplicatedFirst.TypeTorchKernelClient before 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 the Players attribute TypeTorchHoldCharacters = false (or keep CharacterAutoLoads off 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 Hold is 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 follows ReplicatedStorage.TypeTorch's Hold / 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; HoldReason next to Hold (server) and next to Holding (client). Fail-safe: when the client's own generation runs, the server has an ActiveGeneration and 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} on TypeTorch/deploy, so no extra permanent subscription; replies on TypeTorch/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, or BootstrapHeads); (3) the backup build typetorch kernel deploy bakes into the place, ServerStorage.TypeTorchBackup, mounted as a fresh clone with the payload checks (Channel prod, modules only), flagged status().backup, fleet backup = true and h = "backup", alert backup_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); alert boot_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, ActiveGeneration is 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's onClose. 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().clients and the fleet status clients count resent and moved.
    • status().fallback = {hold, backup, peers, chain, recovery, moving, clients}.
  • 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 key TypeTorchAccess); the stored heads key 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.json sets LoadStringEnabled false (kernel deploy --loadstring turns 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. body is JSON text (defaultBranch, channels, access, backend, fleet, analytics, game); sig / sigF are both prod keys' Ed25519 signatures of tt1settings \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} on TypeTorch/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 Studio Registry / BootAssetId overrides 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 topic TypeTorch/rollcall for 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), ClientGeneration and Holding (the holding screen's mode) on ReplicatedFirst.TypeTorchKernelClient. The game's ReplicatedFirst attributes TypeTorchBootScreen = "<ScreenGui name>" (cloned at once, removed when the first client generation runs and the hold ends, at most 60 s) and TypeTorchKernelScreen = 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?) runs fn() 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 into persist). At most DETACHED_MAX (256) run at once (then it errors); one running past DETACHED_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 old fleet = { url, token } is read only while backend is missing (status().fleet.source); hints name typetorch 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, framework and game through api:budgetCount(caller, kind, op, n?); TypeTorch.messaging publishes count as game), 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().budget and every fleet heartbeat (bu) carry a compact summary the backend stores per server.
  • Server TPS (0.4.2, src/server/Budget.luau): RunService.Heartbeat frames are counted into one-second buckets. Every fleet heartbeat carries pf = { a, m, p }: the average TPS and the slowest second since the previous heartbeat, and workspace:GetRealPhysicsFPS() (0.1 steps, about 25 bytes; nil before the first second). Memory is not repeated: it stays in bu.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, a seq heartbeat is sent ahead of the queued reports, so deploy --wait never sees the report before the server's new q.
  • 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 through api:onRemoteDebug), owners only, at most 30 a minute, answers scrubbed of every secret the kernel knows, and posts them to POST <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().remoteDebug has 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> } on TypeTorch/deploy that a backend with OPENCLOUD_API_KEY and TYPETORCH_UNIVERSE_ID publishes 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 what rd = 1 does: 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 per RD_WAKE_GAP (10) s at most, none without a backend in the settings; it never becomes status().lastMessage. status().remoteDebug.wakes / wakesIgnored / lastWakeAt count them. Older kernels ignore the message (no branch).
  • Debug bundles (0.5.3; src/server/Bundle.luau, optional): when this server fails, or shuts down after a failure, it posts what happened to POST <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 as d.bundle); the bundle goes ~3 s later with that id (triggers in between join it, listed in failure.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 swap reports, failing, failedHere, pending, wanted, busy, restoring, the branch's heads, the last 10 refusals), 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.trimmed says 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().bundles has 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 .luau file and exits non-zero on a parse error. npm publish runs it (prepublishOnly), and typetorch kernel deploy runs 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 in scripts/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: a seq heartbeat 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.luau boots the real server kernel with stubbed services (vectors in scripts/smoke-settings.json and scripts/smoke-signatures.json; --verbose prints 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-config and --health-missing, 0.3.8's --messaging, --messaging-studio, --messaging-missing, --client-boot, --client-own, --settings, --settings-missing and --detached, 0.3.9's --rollback-prev, 0.4.0's --errors (settings.backend, heartbeats with bu, a server error and a client batch posted templated with the pid, the budget view), 0.4.2's --perf (heartbeat pf from 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's rd starts the poll, kernel and framework ops, refusals, client logs, no secret in an answer; 0.5.1: a wake on TypeTorch/deploy starts it with quiet heartbeats; 0.5.7: live debug through the real kernel: the declarations on heartbeats, a game.* command, streams while watched, the lm wake), 0.5.3's --bundle and --bundle-clean (a failed deploy's alert with d.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 adds deploy-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).

  1. Bump the version in BOTH package.json and src/shared/Constants.luau (KERNEL_VERSION); bump KERNEL_API and package.json typetorch.kernelApi together, and only on a breaking change to the generation contract (update ServerKernel / ClientKernel in the framework with it).
  2. Run the check and the tests above, merge to main.
  3. Tag and push: npm version <x.y.z> -m "release %s" && git push origin main --follow-tags (or tag v<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.

Released under the MIT License.