Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

Resources#

Status: slice 1 built (2026-09-25) — std/resource has Resource, EmbeddedBytes and EmbeddedText, and a literal where one is expected is resolved by the checker against the file that writes it, inside its package and byte for byte, and becomes a value that holds the stable name (the literal rule of docs/design/URI.md section 9). The build copies and embeds nothing yet: reading the bytes is slices 2 and 3, and ResourceError comes with them.

A file a program needs is named by a string literal, and the signature of the call says what kind of file it is. SpriteSheet.load("./hero.png") ships the file next to the binary and reads it when the program runs; SpriteSheet.embedded("./hero.png") puts its bytes in the binary. Neither is an import, neither is a loader chosen by an extension, and neither runs anything at build time that the author did not write as an ordinary function call. A string literal whose expected type is a resource type is a resource, the way 1 is a Float where a Float is expected. A String variable is never one.

  what the signature says                what the build does              what the caller gets

  embedded(text: EmbeddedText)   ──→  the bytes are static data    ──→  String, synchronous, infallible
  embedded(bytes: EmbeddedBytes) ──→  the bytes are static data    ──→  Bytes,  synchronous, infallible
  load(file: Resource)           ──→  the file ships beside the    ──→  Task<Result<Bytes, ResourceError>>
                                      binary, found relative to
                                      the PROGRAM
  read(path: Path)               ──→  nothing: the compiler has    ──→  Task<Result<..., IoError>>, and the
                                      never seen this path              import of std/fs says it reads a disk

This design adds one parameter kind to the checker and three types to a new std/resource, and it changes one signature that runs on neither implementation today (Sandbox.load). It adds no syntax, no keyword, no annotation and no build step. docs/design/PROJECT.md section 9 says what the project file and the build output layout have to say about it; everything else is here.

The repository is migrating to static and var fn, so a member that belongs to the type is written static origin = Point(0, 0) and static fn from(source: Source): Self, a method lists no self (fn area(): Int), a mutating method is var fn translate(deltaX: Int), and const is optional on a field. Every snippet below is in that form; function types keep their receiver ((var self: SandboxCapabilities) => Void).


1. What the toolchain does today#

Four probes, each a package in a workspace of its own, checked and built with the self-hosted compiler on a stage 0 built in this worktree. A claim below that no probe covers is marked as unproven where it stands.

Probe 1 — a parameter of a type the compiler does not know specially#

/** A stand-in for the `Resource` of this design. */
public type Resource {
  /** Where the file is. */
  path: String
}

/** Takes the type the way `SpriteSheet.load` would. */
fn load(resource: Resource): String {
  resource.path
}

/** A generic parameter, the shape of `Sandbox.load<Value>`. */
fn loadAs<Value>(path: Resource): String {
  path.path
}

print load("./hero.png")
print loadAs<Int>("./level.scene.trb")
print load(Resource("./ok.png"))
error: Expected `Resource`, found `String`
  --> ../probe/baseline/src/main.trb:19:12
   |
19 | print load("./hero.png")
   |            ^^^^^^^^^^^^

error: Expected `Resource`, found `String`
  --> ../probe/baseline/src/main.trb:20:19
   |
20 | print loadAs<Int>("./level.scene.trb")
   |                   ^^^^^^^^^^^^^^^^^^^

2 problems in 1 of 2 files

A string literal adapts to nothing. There is no mechanism to build on and no partial one to finish: section 3 is entirely new checker work. The generic case behaves like the plain one, so the parameter kind does not have to fight inference. One further fact from the same probe: public native type Resource in a package that is not part of the standard library is error: `native` is only allowed in a package of the standard library, so the three types of section 4 must live in std, which they do.

Probe 2 — how a string literal that is a module constant is emitted natively#

/** A plain string literal as a module constant - the shape an embedded text would have. */
public const schema = "create table users (id integer primary key)"

/** The name of the table, so the next one is interpolated rather than plain. */
public const table = "users"

/** An interpolated one, for comparison. */
public const computed = "create table {table}"

Both constants come out as static data and neither is a FunctionKind.ConstantCell:

/* The storage of every string literal: immortal, so a write through one always copies. */
static struct { torb_header header; uint32_t capacity; uint8_t data[43]; }
  s_probe_x2f_literal_data_schema_storage = { TORB_IMMORTAL_HEADER(TORB_BLOCK_BYTES), 43, { 0x63, 0x72, 0x65, ... } };

/* The compile-time constants of the program. */
static const torb_text s_probe_x2f_literal_data_schema = { (torb_bytes *)&s_probe_x2f_literal_data_schema_storage, 0, 43 };

The interpolated one is folded too — "create table {table}" becomes an eighteen-byte static block, not a call of Add.add at first read. So an embedded text needs no new emission machinery at all: EmbeddedText.text() is exactly this, and section 8's folding has a working precedent in the emitter that exists.

And the purity rule of a module constant is narrower than section 8 needs. The same probe, with public const computed = "create table " + "users":

5 | public const computed = "create table " + "users"
  |                         ^^^^^^^^^^^^^^^^^^^^^^^^^
  = A module has no initialization order: literals, the operators of the number types and of `Bool`, interpolation,
    tuple, list and map literals, constructor calls of such, and other compile-time constants. `Point(0, 0)` is one,
    `Point.origin()` is a call and is not

So static hero = SpriteSheet.embedded("./hero.png") — L3's motivating line — does not check today, because embedded(...) is a call. Section 8 is where that is dealt with, and the answer is not to widen the rule now.

Probe 3 — how large a literal the C back end emits#

A generated module with one string literal, built end to end. gcc 13.2, 16 cores, release, Windows.

PayloadThe .trb filecheckbuild --emit-cfull buildthe Cthe binaryit runs
1 MB of ASCII1 048 624 B9.1 s16.4 s20.9 s6 295 281 B1 208 253 Byes
16 MB of ASCII16 777 264 B66.0 s143.0 s195.7 s100 667 124 B16 936 893 Byes, in 0.03 s

Nothing breaks at either size, and the cost is linear and steep. The emitter writes one 0x.., per byte, so the C is about 6.0 times the payload; the C compiler is about a quarter of the wall time at 1 MB and about a quarter at 16 MB, so no single stage dominates. docs/BACKEND.md states no size limit for a literal, and the C standard's 4095-character limit on a string literal does not apply, because the emitter never writes one — it writes a byte array initializer, whose only standard minimum is the 65 535-byte object limit that every real C compiler is far past. The practical guidance section 9 draws from this: under a megabyte, embedding is free; at sixteen, it is three minutes of build time somebody will notice.

Probe 4 — is Sandbox.load usable at all today?#

use Sandbox from "std/sandbox"

/** The receiver a loaded script configures. */
public type ServerConfig {
  /** The port the server listens on. */
  var port: Int = 8080
}

const chosen = "./does-not-exist.trb"

const missing = Sandbox.load<ServerConfig>("./nowhere/at/all.trb")
const computed = Sandbox.load<ServerConfig>(chosen)

print missing.isOk()
print computed.isOk()
2 files, no problems

The checker accepts all of it — the generic argument, a trailing capability block, a path that names no file, a path that is a computed variable. Then:

$ torb run ../probe/sandbox
error: Unknown name `Sandbox`
  at probe/sandbox/src/main.trb:11:16

$ torb run ../compiler build ../probe/sandbox
error: a value of type `Script` is not supported by the native back end yet (at probe/sandbox/src/main.trb:11:16)
error: the type `Script` is not supported by the native back end yet
2 problems the native back end cannot compile yet, nothing was built

Sandbox is a signature and nothing else: it runs on neither implementation. Two things follow. Changing its signature (section 5) costs nothing, because no program can be relying on the current one. And L2 (section 7) cannot be built before the VM, which is what puts it in slice 5.

2. What other systems do#

SystemCompile-time embeddingShipped file at run timeHow the file is namedWho decides which
Rustinclude_bytes!/include_str!, a macronothing; you write your own path arithmetica literal, relative to the filethe macro you call
Zig@embedFile, a builtinnothinga literal, relative to the filethe builtin you call
Go//go:embed on a variable, with embed.FS for a treenothinga pattern, relative to the directorya comment above a declaration
Denoan import attribute (with { type: "text" })Deno.readFile under a permissiona specifier, relative to the filethe attribute
Node, bundlersa loader chosen by extension, configured in the bundlerfs.readFilea specifiera configuration file, far from the call
SwiftPMnothing; everything is a bundle resourceBundle.module.url(forResource:), a run-time lookup by namea resources: list in the manifest, then a string at run timethe manifest
Dart, FlutternothingrootBundle.load(key)an assets: list in pubspec.yaml, then a string at run timethe manifest

What we take. Rust's and Zig's shape — a file named by a literal that the compiler resolves, relative to the file that writes it — and nothing of how they spell it. include_bytes! and @embedFile are a macro and a builtin because those languages have no way for a parameter to say "I take a literal". TorbScript does: that is what lazy and Expression<Value> already are, and CONCEPT's rule is exactly the one this needs — "a call has one signature, and that signature says how its arguments are read". So the mechanism is an ordinary function with an unusual parameter type, and the resource types can be used by any function anybody writes, which is more than a builtin gives.

Go's embed.FS is the one thing worth watching: a whole directory as one value, which section 6 deliberately leaves out for now and says why.

What we leave.

  • A bundler's loader table. import x from "./a.svg" means a URL, a string, a React component or a data URI depending on a configuration file nobody reads, and the same source file means different things in two builds.
  • A manifest list plus a run-time string key (SwiftPM, Flutter). It is two sources of truth that can disagree, and the disagreement is a run-time failure with a string in it. The call site already knows; asking the manifest as well only adds a way to be wrong.
  • A comment that declares something (//go:embed). A declaration that lives in a comment is an annotation, and CONCEPT has no annotations.
  • A macro or a builtin. There are none, and this design needs none.

3. A literal adapts; a String never converts#

A string literal whose expected type is a resource type is a resource. This is the rule that already exists for numbers, applied to one more expected type: the literal takes its type from where it is written, and an expression that is not a literal takes no type from anywhere. There is no Into<Resource>, no From<String> and no coercion: docs/design/PATH.md records the same wish for Path and calls it a candidate for later, which is the same mechanism and would be the same decision.

Exactly these expressions are a resource literal:

The expressionIs itWhy
load("./hero.png")yesa string literal whose expected type is a resource type
load(sheet: "./hero.png")yesa label changes nothing about where the expected type comes from
["./a.spr", "./b.spr"] for List<Resource>yes, each elementthe element's expected type is the resource type; this is how a set of files is written until section 6's ResourceDirectory exists
fn load(sheet: Resource = "./default.spr")yesa default is written at the declaration, so it resolves against the declaring file, which is section 6's rule unchanged
load("./sheets/{name}.spr")nointerpolated
load("./a" + "/b.spr")noa call of Add.add, and probe 2 shows the module-constant rule already draws that line here
const path = "./hero.png" then load(path)nothe binding is a place with no expected type, so the literal is a String there and a String never converts
static path = "./hero.png" then load(path)nothe same, and see below
load(chosen) for any variablenothe value is known when the program runs
load(other) where other: Resourceyes, triviallyit is already a resource; a resource is an ordinary value once it exists

A constant initialised by a literal is deliberately not a resource literal, and this is the one place the rule gives something up. const one = 1 does not make one usable where a Float is expected either, and the value of having one rule for literals is larger than the value of the exception. The wish behind the exception — naming the assets of a package in one place — has a better answer that costs nothing:

extend SpriteSheet {
  /** The hero's sheet, embedded once. */
  static hero = SpriteSheet.embedded("./hero.png")
}

The literal is at the call site where its expected type is right there, and the constant holds the loaded sheet rather than a path — typed, built once, and the compiler still sees the file. A constant that holds a path holds the one thing about a resource that is worth nothing to a reader. Section 8 is what makes this form legal.

The messages#

error: A `Resource` is a file the compiler resolves, so it has to be written as a literal here
 --> src/sprites.trb:7:32
  |
7 | const sheet = SpriteSheet.load(chosen)
  |                                ^^^^^^
  = `chosen` is a `String`, and its value is only known when the program runs. A path chosen at run
    time is a `Path`: `File.readText(path)` reads one, and `use File from "std/fs"` is what says so
error: A `Resource` is a file the compiler resolves, so it cannot be interpolated
 --> src/sprites.trb:7:32
  |
7 | const sheet = SpriteSheet.load("./sheets/{name}.spr")
  |                                ^^^^^^^^^^^^^^^^^^^^^
  = Write one literal per file, or read a path at run time with `File.readText(path)`
error: There is no file `sheets/hero.spr`
  --> src/sprites.trb:12:33
   |
12 | static hero = SpriteSheet.embedded("./sheets/hero.spr")
   |                                    ^^^^^^^^^^^^^^^^^^^
   = A resource path is relative to this file, and `acme/game/src/sheets/hero.spr` is not there

A missing file is a build error at the line that names it, which is the single largest thing this design buys and the one that has nothing to do with embedding.

4. Three types, one parameter kind#

/**
 * A file that ships with the program and is read while it runs. The build puts it beside the binary; the run time
 * finds it relative to the *program*, never to the working directory.
 */
public native type Resource {
  /** What the file is called, project-relative and always with `/` - what a diagnostic and a log print. */
  name: String

  /** The bytes. Fails if the file was not shipped or cannot be read, and waits, because on the web it is a fetch. */
  native fn bytes(): Task<Result<Bytes, ResourceError>>

  /** The bytes as text. Bytes that are not UTF-8 are a `ResourceError` and never a replacement character. */
  fn text(): Task<Result<String, ResourceError>>
}

/** A file whose bytes are in the binary. Synchronous and infallible: the compiler has already read them. */
public native type EmbeddedBytes {
  /** What the file is called, project-relative. */
  name: String

  /** The bytes, as static data. */
  native fn bytes(): Bytes
}

/** A file whose text is in the binary. The compiler checked that it is UTF-8, so reading it cannot fail. */
public native type EmbeddedText {
  /** What the file is called, project-relative. */
  name: String

  /** The text, as static data. */
  native fn text(): String
}

Three types and one rule. For the checker they are one parameter kind: a parameter whose type is one of the three takes a literal, the literal is resolved against the writing file, and the resolved file is recorded with the call. Everything after that is a property of the type and not a new mechanism.

Why the embedding type is two types and Resource is one#

The asymmetry is not taste, it follows from where failure lives.

  • An Embedded* read cannot fail, because the bytes are in the binary. So if there were one embedding type with both bytes() and text(), text() would have to be fallible — for a file the build had in its hands and could have checked. The compiler knows at the literal whether the bytes are UTF-8; throwing that away and handing the program an Option is giving up a build error to gain nothing. Two types keep text() infallible and put the UTF-8 check where it belongs: at the literal, as a build error naming the byte offset.
  • A Resource read can fail anyway — the file may be missing, truncated, or on a network that answered 404. So folding "these bytes are not UTF-8" into the same ResourceError costs nothing and adds no type. One Resource with both bytes() and text() is right for exactly the reason two embedding types are.

Why not one type whose member decides#

The alternative is one Resource type where resource.bytes() reads the shipped file and resource.embeddedBytes() means "put this in the binary instead". Rejected, for two independent reasons.

  1. It needs whole-program flow analysis, and it does not terminate at analysis. To decide whether to ship or embed, the compiler must know at the literal which member will eventually be called on the value. A Resource handed through three functions makes that an interprocedural question; a Resource stored in a field, put in a list, returned from a trait member or captured by a closure makes it undecidable. And the failure when the analysis cannot answer is not a diagnostic — it is a binary sixteen megabytes bigger than intended, or a file that was not shipped and is missing on a customer's machine. A rule whose failure mode is silent is worse than a rule that asks for one more word.
  2. The two have different signatures and always will. Resource.bytes() is Task<Result<Bytes, ResourceError>>; EmbeddedBytes.bytes() is Bytes. A single type would have to answer the fallible, waiting one in both cases, which throws away precisely what embedding buys. The point of embedding is not that the bytes are in the file, it is that reading them is a load instruction.

This is the same argument CONCEPT makes for lazy and Expression<Value>: the signature decides at the call site, and nothing about a call is a function of what happens to its result.

Why Resource waits#

Resource.bytes() returns a Task, on every target, including the ones where the file is on a local disk and the read is instantaneous. A Task<Value> is a return value like any other — the language has no async keyword and nothing about a signature is "coloured" by one — so the only question is whether waiting is part of what this call does, and it is.

  • The web forces it. In a page, a shipped file is a fetch and there is no way to block. If the native signature were synchronous, the same source would need a different signature on the web target, and "one program, both back ends" would stop being true for every program that reads a resource.
  • CONCEPT already has the rule. "Asynchrony lives in the type system, not in a keyword. A function that returns Task<Value> may call await()." A Task whose value is already there is exactly what File.add returns today on native, so the shape and its runtime exist.
  • What a caller writes is one method call. const bytes = sheet.bytes().await()? — await() inside a function that itself returns a Task, and ? for the Result inside it, both of which are visible where they happen. That is the whole of what the Task asks of a caller.
  • The escape is the point of the split. Code that must not be a Task — a decoder, a Describe implementation, a static initializer — uses EmbeddedText/EmbeddedBytes and is synchronous and infallible. The two types are not an optimization with the same interface; they are the two answers to "may this wait", and choosing one is the whole decision a caller makes.

The comparison worth drawing is docs/design/ENCODING.md, where a decoder deliberately does not return a Task: a decoder is handed bytes somebody else read, so waiting is not part of what it does and a Task there would describe the call wrongly. A resource read is the IO, so the Task describes it correctly. The rule is the same in both places — the return type says what the call does — and it lands on opposite answers because the calls are different.

5. Three verbs across std#

A naming convention, so that a reader of any signature in the standard library knows what the build will do without looking anything up:

VerbThe parameterWhen it happensFallibleWaitsWhat the import says
embedded(...)EmbeddedBytes / EmbeddedTextbuild timenononothing: it is data in the binary
load(...)Resourcerun time, from beside the programyesyesnothing: the build shipped the file
read(...) / open(...)Pathrun time, from wherever the program was pointedyesyesuse File from "std/fs" — this touches the user's disk

The third row is the one that carries a capability, and the first two are the ones that do not. That is worth being explicit about, because it looks like a security claim and is not one: an EmbeddedBytes is bytes the build put in the binary, and a Resource is a file the build put beside it — in both cases the author of the program chose the file, at compile time, and a reader can see every one of them in the locked manifest. Reading them grants nothing that compiling the program did not already grant. std/fs stays the import that means "this program reads files the user names", and nothing in this design weakens it.

Sandbox, which needs all three#

Sandbox.load today takes path: String and resolves it against the project directory, which for a deployed program is the working directory. Under the three verbs that is the wrong name for the wrong thing: a configuration file of a deployed program is usually a user's file, not a shipped one, and the two cases are different enough that one member cannot serve both.

public native type Sandbox {
  /** Checks an embedded `.trb` script against `Value`. The bytes are in the binary, so only the checking can fail. */
  native fn embedded<Value>(
    script: EmbeddedText,
    capabilities: (var self: SandboxCapabilities) => Void = {},
  ): Result<Script<Value>, SandboxError>

  /** Checks a shipped `.trb` script against `Value`. The compiler resolves the literal and checks the script (section 7). */
  native fn load<Value>(
    script: Resource,
    capabilities: (var self: SandboxCapabilities) => Void = {},
  ): Task<Result<Script<Value>, SandboxError>>

  /** Checks a script the program was pointed at while it ran: `--config`, a user's scene, a plug-in directory. */
  native fn read<Value>(
    path: Path,
    capabilities: (var self: SandboxCapabilities) => Void = {},
  ): Task<Result<Script<Value>, SandboxError>>
}
  • A script the program holds as text is Sandbox.loadText (docs/design/URI.md gap 13). The text came from a Storage, a request or a database, so there is no path to resolve and no file to read: the member takes the String and a name for errors, and is checked and limited as a read is. It stays out of the three verbs because it names no place, and std/sandbox does not depend on std/storage.
  • Today's Sandbox.load becomes Sandbox.read. What it does today — take a path that is known when the program runs and resolve it against the working directory — is exactly what read means, so the behaviour keeps its meaning and only loses the name. The name goes to the shipped case, which is the one that deserves the short word.
  • Changing it costs nothing. Probe 4: Sandbox runs on neither implementation, so there is no program to break.
  • project.trb is a read, not a load. The toolchain is handed a directory on a command line and finds a project.trb in it; that path is not known when the toolchain is compiled. docs/design/PROJECT.md section 8's grant is therefore written against read, and the locked manifest changes nothing about that.
  • A scene file is a load. docs/design/ECS.md writes Sandbox.load<Scene<World>>("./scenes/level.trb"), and under this design that literal is a Resource: the scene ships beside the game, the compiler type checks it at build time (section 7), and instance "scenes/asteroid.trb" stays what ECS says it is — a method of the receiver, resolved by the host against the scene root, and not a resource literal at all, because it is written in a script and a script has no compiler to resolve it.

6. Relative to what, and what a resource is called#

A resource literal is relative to the file that writes it. That is use's rule, and it is the only rule that works: a library's SpriteSheet.embedded("./hero.png") must find the library's own hero.png whoever calls it, and an application's SpriteSheet.load("./hero.png") must find the application's.

  • A library's assets travel in its package. They are files below the package directory, so they are part of what is published (docs/design/PROJECT.md section 3), and the settings section of its project.lock.trb lists every one the package's own code names.
  • An application's literal is the application's file, even when it is passed to a library's function. The literal is written in the application, so it resolves there. Nothing about the library's location enters into it.
  • The resolution happens once, in the checker, and what is recorded is a name.

The stable name#

A resource is identified at run time by <owner>/<name>/<path inside the package> — the same text the compiler already builds for a panic site (stablePathOf in ir/lower/lower.trb, stable_path in stage 0's program.rs), with / on every platform and no directory of the build machine in it. acme/game/src/sheets/hero.spr is a stable name. The layout beside the binary is that name under the program's resource directory:

build/<profile>/<program>                                      the binary
build/<profile>/<program>.resources/acme/game/src/sheets/hero.spr

Using the same text the panic paths use is not a saving of code, it is a saving of one thing a reader has to know: a file's name in an error message is a file's name in an error message, whether the error is a panic or a missing resource.

A stable name is not a URI and gets no scheme of its own: docs/design/URI.md section 14 says why there is no embedded: and no resource:, and its section 9 is the general form of the literal rule this document's section 3 states.

  • Two literals that resolve to one file are one resource. The identity is the stable name after normalization, so "./hero.png" and "./a/../hero.png" in one file, and a literal in two packages that reaches the same file, all name the same resource; it ships once and is embedded once. A .. is allowed as long as it does not leave the package.
  • The bytes are read where the literal is checked, and embedded once per stable name (slice 2). The checker reads the file of an EmbeddedBytes or EmbeddedText literal when it checks it (an unreadable file, and an EmbeddedText that is not UTF-8 with the offset of the first bad byte, are errors at the literal), and the lowering turns the files into the bodies of the natives embeddedText(name) and embeddedBytes(name) of std/resource: one branch per stable name over constants. The statics of a program hold no blob yet, so the bytes of an EmbeddedBytes are stored as Base64 text and std/resource decodes them; a blob static replaces that without changing a call site.
  • A resource outside the package directory is an error. "../../secrets/key.pem" names a file that is not part of the package, so it cannot be published and the build cannot ship it. The message names the package and its directory, exactly like section 6 of docs/design/PROJECT.md does for a use that climbs out.
  • Case is compared byte for byte, on every platform. The compiler resolves a literal against the listing of the directory, not against the file system's own matching, so "./Hero.png" next to a file called hero.png is an error on Windows and macOS too, where the open would have succeeded. docs/design/PATH.md already decided this for paths ("not case insensitive, on any platform, ever"), and the reason is sharper here: the alternative is a build that works on the author's machine and fails on the deployment's, which is the single most expensive bug shape a resource system can have. It costs one directory listing per directory that holds a resource, cached for the build.

Directories and globs — not yet#

A ResourceDirectory type, or a glob in the literal, is the obvious next thing and is deliberately not part of this design. It has questions this design does not have to answer: is the listing fixed at build time or read at run time; is a file added to the directory after the build part of it; what is a directory on the web, where there is no directory to list. Until somebody has a real case, the answer that costs nothing is a list literal, which section 3 already allows:

extend Level {
  /** Every level of the game, in order. */
  static all = [
    Scene.embedded("./levels/1.scene.trb"),
    Scene.embedded("./levels/2.scene.trb"),
    Scene.embedded("./levels/3.scene.trb"),
  ]
}

Explicit, diffable, checked file by file, and it fails at build time when a level is renamed. When it is a hundred levels, that is when ResourceDirectory earns its questions, and Go's embed.FS is the shape to start from.

7. L2: a .trb resource is type checked at build time#

Sandbox.load<Scene>("./levels/1.scene.trb") names a file the compiler can open and a type the compiler knows, so a type error in a scene or in a shipped configuration is a build error. Execution stays exactly where it is: at run time, in the sandbox, with its own heap and its own limits.

What the checker needs#

CONCEPT already says how a script is checked: as the body of (var self: Value) => Void, with the file scope "the prelude, and nothing else", plus whatever the module allowlist adds. Two of the three are static at the call site — the path is a literal, the receiver type is the type argument. The third is the question.

The allowlist is granted in the trailing block, and the block is a closure. modules "acme/game/components", "std/linear" looks like a list of literals and usually is one, but it is a receiver closure body, so it may hold an if, a loop or a computed name. So the rule has to say what happens then, and "check it when we can" is the one answer that must not be chosen: a checker rule that is sometimes on is worse than one that is off, because a project loses build-time checking by an edit somewhere unrelated and nothing says so.

Decided: Sandbox.load and Sandbox.embedded require a statically readable capability block, and that is part of their signature's promise.

  • A capability block is statically readable when every statement in it is a command call of a SandboxCapabilities member whose arguments are plain literals. That is the same rule docs/design/PROJECT.md section 10 applies to the manifest's static nine, one level down, and it covers every capability block in the repository's documents and examples.
  • A block that is not statically readable is an error at the call site, naming the statement and pointing at Sandbox.read — which takes a Path, checks nothing at build time, and is the member for a script whose shape is decided while the program runs.
  • So the three members differ in exactly the property that makes L2 possible: embedded and load know the file and the allowlist at build time and check the script; read knows neither and checks it when it loads.

What it means for incremental checking and the module graph#

A .trb resource is a node of the module graph with an unusual shape: it has no name, nothing can import it, and its scope is the prelude plus the allowlist rather than the package.

  • Its edges are three, and the graph already has the last two kinds: the file itself, the receiver type, and every module in the allowlist. A change to any of them re-checks the script, and a change to the script re-checks nothing else, because it exports nothing.
  • It is checked once per call site, not once per file. The receiver type and the allowlist come from the call, so two calls that load one file against two receiver types are two checks. That is also the cache key: (stable name, receiver type, allowlist).
  • It does not take part in the export fixpoint. A module's exports are computed to a fixpoint because modules can see each other; a script exports nothing and nothing can see it, so it is a leaf. Adding leaves is the cheapest thing that can happen to that algorithm.
  • A cycle is impossible. A script cannot be imported, so nothing can point back at one. Scene-to-scene nesting is a run-time relation through the receiver (instance is a method, docs/design/ECS.md), not a graph edge — which is why ECS gives it a depth limit and a SceneError rather than a compile-time cycle check.
  • The cost is that torb check now opens files that are not .trb modules, and that a scene with a type error makes torb check red. Both are the feature.

8. L3: the loader is an ordinary function#

There is no loader mechanism. SpriteSheet.embedded(...) is a function somebody wrote, and its return type is the type of the thing. Nothing in the language knows what a sprite sheet is, no extension maps to anything, and adding a format is adding a function.

extend SpriteSheet {
  /** Parses a sheet whose bytes are in the binary. */
  static fn embedded(file: EmbeddedBytes): SpriteSheet {
    SpriteSheet.parse(file.bytes())
  }

  /** Reads a sheet that ships beside the program. */
  static fn load(file: Resource): Task<Result<SpriteSheet, ResourceError>> {
    Ok SpriteSheet.parse(file.bytes().await()?)
  }
}
extend SpriteSheet {
  /** The hero's sheet. */
  static hero = SpriteSheet.embedded("./hero.png")
}

That last line is a static whose initializer is a call, so by docs/BACKEND.md it is a FunctionKind.ConstantCell: built once, at its first read, into a block that is never freed, with no module initialization order anywhere.

It does not check today — probe 2: the purity rule for a module or type constant allows literals, the number and Bool operators, interpolation, collection literals, constructor calls of those and other constants, and never a function call. That rule stays exactly as it is until the VM exists. With the VM it gains exactly one case: a call of a function the compiler can evaluate at build time, which is what the rest of this section defines. Before then, a loader is a fn and not a static, and the only thing lost is that the sheet is parsed at every call.

What the compiler may do, and what "pure" has to mean#

With the VM, the compiler MAY evaluate such an initializer at build time and embed the result. The embedding format is docs/design/ENCODING.md's principle: a value is its constructor call, so the built SpriteSheet is written back as the constructor call that produces it, and that call is what the binary holds. This is constant folding — the program behaves identically either way — and it turns a corrupt asset into a build error.

"Pure", exactly, and all six conditions have to hold:

  1. It terminates. Enforced by the VM's step and time limits. Exceeding them is a build error naming the constant, never a hang.
  2. It reads nothing but its arguments and other compile-time constants. No IO of any kind. An EmbeddedBytes is not IO — its bytes are an argument the compiler already holds. A Resource is IO and can never be folded: the file is not shipped yet when the build runs, and there is nothing to read. That is the sharp line the two types draw a second time: embedded folds, load never does.
  3. It is deterministic. No clock, no environment, no process, no randomness, and no iteration order that depends on the address of anything — a map whose order came from pointer identity would make two builds of one commit differ, which is the one property the whole toolchain is built on. The environment is closed here even though docs/design/PROJECT.md section 8 opens it for a project.trb, and the two are not in tension: a manifest's environment read produces a setting, which is recorded by name in the lock and visible to everybody who reads the package, while a folded initializer produces a value inside the binary that nothing records and nobody can see.
  4. It does not panic. A panic while folding is a build error at the line of the constant, carrying the panic's own message. That is the feature and not the cost: a truncated PNG stops the build instead of crashing on a customer's machine.
  5. Nothing it touches is shared. A shared type has an identity — a handle, a socket, a mutable cell somebody else can see — and an identity is not a value, so there is no constructor call that reproduces it.
  6. It produces no function value. A closure's code is in the binary but its captures are not its constructor, so there is no call that writes it back. A value with a function-typed field is not embeddable, and the message says so at the field.

What the value's type must satisfy, so that "a value is its constructor call" can actually write it:

  • It round-trips through its own constructor: what Encode writes, Decode reads back as the same value. For a type with the derived implementations (docs/design/ENCODING.md section 3) that is free. A type that implements Encode by hand is embeddable only if its hand-written form is still the constructor call Decode reads — and if it is not, the compiler cannot tell, so a hand-written Encode disqualifies a type from folding and the message names it.
  • Every field's type satisfies the same, transitively, including the items of a collection and the payload of a case.
  • No private field is in the way. A private field takes the constructor with it (the constructor-probe round found this), so a type whose constructor is not reachable from where the embedding is written cannot be written back. The embedding is emitted inside the defining package, which is where the constructor is reachable, so this holds by construction — but it is why the embedding cannot simply be a blob the linker drops in from elsewhere.

What is NOT promised#

  • Folding is never guaranteed. The compiler may fold. A program must behave identically whether it did or not, which the six conditions ensure, and a build is free to skip folding for a value whose embedding would be larger than the work it saves.
  • There is no way to demand it. No keyword, no attribute, no constexpr. Such a keyword is the door to compile-time programming, which CONCEPT lists as a possible future and not as a v1 feature, and the observable difference between a folded and an unfolded constant is build time and binary size — which are not semantics. The one thing somebody legitimately wants, "tell me this was not folded", belongs in torb build --statistics.
  • It is not compile-time evaluation anywhere else. static and module constants are the only place, because they are the only place with no observable timing. A folded expression in a function body would be an optimization the back ends may do and this document says nothing about.
  • It does not promise a smaller binary. A decoded SpriteSheet may be larger than the PNG it came from. The reasons to fold are that the work disappears and that a corrupt asset becomes a build error; bytes are not one of them.

9. What the build knows because of this#

After checking, the build has the complete set of files the program needs, with a type on each. That set is what every item below is.

  • Packaging. Each Resource is copied to <program>.resources/<stable name>; each EmbeddedBytes and EmbeddedText is emitted as static data. Nothing is guessed and nothing is listed twice.

  • The published package's file list. Every literal in the package's own files becomes a resource "..." line in the settings section of its project.lock.trb (docs/design/PROJECT.md section 8), so a consumer sees the non-code files of a dependency without evaluating anything, and torb publish refuses a package whose code names a file the archive does not contain.

  • Watch mode. The resource set is a file set, so torb build --watch watches it. Today it can only watch .trb files, because they are the only files it knows exist.

  • The VM (milestone 7). Today torb run interprets the program in the VM and stages its resources: the files are copied beside a name in the toolchain's cache that the program runs as, so CurrentProcess.executable() and a Resource read behave as in a binary. Once the VM interprets from the source tree, the file is simply there: a Resource resolves to the file at its stable name inside the workspace, and nothing is copied. An EmbeddedText is read from the tree at the moment its constant is first read. That is observationally identical except for one case — a program that rewrites its own asset while it runs would see the new bytes in the VM and the old ones in a binary — which belongs in the VM-versus-binary list, not in the language. (Stage 0, deleted on 2026-09-22, behaved the way the VM will.)

  • The C back end. Static data, measured in probe 3: about six bytes of C per byte of payload, nothing breaking at 1 MB or at 16 MB, 21 s and 196 s of build time respectively — about twelve seconds of build time per embedded megabyte. torb build reports the embedded total per program, and the project file sets two levels on it:

    resources {
      embeddedWarningAbove = 4.megabytes()
      embeddedErrorAbove = 64.megabytes()
    }

    Above the first, the build report says so and names Resource as the other option; above the second, the build fails and names the three largest contributors. The defaults come straight from the measurement: four megabytes is about a minute of build, which is when somebody notices a rebuild and deserves to be told why, and sixty-four is about thirteen minutes and four hundred megabytes of generated C, which is the accident — an assets/ directory embedded by a loop — rather than a trade anybody made. The total is what is measured and a single file has no limit of its own: one oversized file blows the total too, while a hundred files of two hundred kilobytes is exactly what a per-file limit would miss. docs/design/PROJECT.md section 9 has the vocabulary, why the low level is a line on the build report rather than a new diagnostic severity, and why neither setting is static.

  • The conformance suite. One program per behaviour, byte-identical on both implementations: an embedded text, an embedded binary whose bytes are not UTF-8, a shipped resource read successfully, a shipped resource that is missing at run time, and a program that prints a resource's name. The last one matters most, because the stable name is in every error message, so it is in every .expected file, so the two implementations are held to one spelling of it.

  • The web target, later. Resource.bytes() is a fetch of <program>.resources/<stable name> relative to the module's URL; an Embedded* is a byte range in the wasm data segment. Both signatures are already right for that, which is the whole reason Resource waits (section 4).

10. Migration#

Six slices. Each one lands with the repository checking green, torb test passing, canon --check clean and the conformance suite comparing the two implementations. Slices 1 to 4 need nothing that does not exist; slices 5 and 6 need the VM (7.x).

All of this lands after the repository has moved to static and var fn, and after docs/design/PROJECT.md's slices 1 to 4, because a resource's stable name is a package-relative path and section 3 of that document is what settles which files belong to a package.

#SliceFilesRisk
1The parameter kind. std/resource with Resource, EmbeddedBytes, EmbeddedText; the checker's literal rule and its diagnostics; resolution against the writing file with a directory listing for case; the recorded stable name in the IRstd/resource/*, compiler/src/semantics/checker/literal.trb, compiler/src/project/{read,source-tree}.trb, compiler/src/ir/lower/literal.trb, compiler/tests/literals.test.trbDone (2026-09-25), with docs/design/URI.md slice 7. ResourceError moved to slice 3, the first slice that can fail a read
2Embedding. EmbeddedBytes.bytes() and EmbeddedText.text() as static data in the C back end; the UTF-8 check at the literal; the first conformance programscompiler/src/backend/c/*, tests/conformance/Low. Probe 2 says the emitter already writes exactly this shape for a string literal
3Shipping. <program>.resources/, the run time finding the program's own directory, Resource.bytes() as a ready Task, ResourceErrorruntime/*, compiler/src/cli/build.trbDone (2026-10-27): CurrentProcess.executable() was the platform call on every platform, so no new native; torb test ships nothing, torb run stages for the VM
4The build's file set. Packaging, the resource lines of the lock's settings section, --watch, the embedded total in the build report and in --statistics, the resources { } budget and its two levels, the move of kilobytes/megabytes/gigabytes from std/sandbox to std/numbercompiler/src/cli/*, compiler/src/project/*Low, and it is the slice that pays for slice 1
5L2. Sandbox.embedded/load/read; the literal capability block and its diagnostic; the script as a leaf of the module graph with its three edges and its cache keystd/sandbox/src/lib.trb, compiler/src/semantics/{graph,check}.trb, the VMBlocked. Probe 4: Sandbox runs on neither implementation
6L3. The one new case in the module-constant purity rule; folding a pure initializer in the VM; the embedding through Encode; the six conditions as diagnosticscompiler/src/semantics/checker/declaration.trb, compiler/src/ir/*, the VMBlocked, and the largest. It is compile-time evaluation with a fence around it, and every one of the six conditions is a message somebody will read

The prose. A new docs/standard-library/resource.md; docs/standard-library/sandbox.md (three members instead of one); docs/tooling/torb-build.md (the output layout and the embedded total); docs/design/ECS.md's loader snippet; CONCEPT's decision log gains one entry; docs/internals/index.md lists this document, which is already done.

11. What this is not#

  • Not a loader chosen by an extension. .png means nothing to the compiler, and there is no table anywhere that maps a suffix to a function. The function is chosen by the caller, which is the thing bundlers give up and then spend a configuration language getting back.
  • Not use logo from "./logo.png". An import binds names, and an asset has no names to bind. Making it an import would be three of CONCEPT's non-goals at once: it is a build script ("installing never runs code, there are no build scripts"), it makes an import run something ("nothing runs when a module is imported", which is what makes cyclic imports free and lets the checker compute exports to a fixpoint), and it needs the checker to know a type before the code that produces it has run — so either the loader declares types, which is meta-programming, or the checker evaluates arbitrary user code, which CONCEPT lists as a possible future and not a v1 feature. docs/design/PROJECT.md section 6 reserves the dot in a module component so that this stays unavailable rather than half-available.
  • Not an asset pipeline. Nothing converts, compresses, resizes, atlases or transcodes. A loader is a function, and a function is not a pipeline. A project that wants a texture atlas builds one and checks it in, or writes a program that builds one and runs it — and that program is an ordinary program line (docs/design/PROJECT.md section 4), run by a person, not a hook the build fires.
  • Not a list in the manifest. SwiftPM's resources: and Flutter's assets: are a second source of truth that can disagree with the call sites, and the disagreement is a run-time failure with a string in it.
  • Not a capability. An Embedded* is data in the binary and a Resource is a file the build shipped; in both cases the author chose the file at compile time and every one is listed in the locked manifest. use File from "std/fs" stays the import that means "this program reads files the user names", and nothing here weakens it.
  • Not a virtual file system. There is no mounted tree, no open("res://…"), no run-time lookup by string. A resource is reached through the value the literal produced, and a program cannot ask for a resource it did not name.
  • Not a directory or a glob, yet. Section 6 says what a ResourceDirectory would have to answer first, and what to write until then.
  • Not a Path. A resource has a name, not a path: it is project-relative, always /, and it is not a thing std/fs will open for you. docs/design/PATH.md's Path is for files the program is pointed at.
  • Not a change to how a script runs. L2 checks a .trb resource at build time and changes nothing about execution: it still runs in the sandbox, at run time, with a heap and limits of its own, and Script.apply still reports what went wrong while it ran.

12. Open#

Nothing is open in this document. The three questions the previous round left — one embedding type or two, whether the Task on Resource.bytes() is the right trade, and whether an oversized embedding is a note or an error — are answered above and in docs/design/PROJECT.md section 9: two types, the Task stays (a return value is not a colour), and two configurable levels of which the upper one fails the build.

Esc