The Project File#

Status: partly implemented — the toolchain reads the static subset of project.trb from the syntax tree and refuses one of its settings that is computed, and where a setting it uses is computed it evaluates the manifest in the sandbox of section 8 and reads that (docs/design/SCRIPTS.md slice 5, section 12's slice 7); torb publish reads the evaluation too, and the package manager of milestone 8 resolves (section 6a), writes and reads project.lock.trb (section 11) and builds from it. Since 2026-09-27 the names of the files decide what a package produces (section 3), build { } and test { input } are gone, program lines, torb run <name> and a torb build of every program are built (section 4), and so are the two profiles with their profile blocks and the output layout per profile and target (section 5), the refusal of a relative path that leaves its package (section 6) and torb lock with its --check gate (section 8). What is not built is listed at the end of section 12.

The names of the files say what a package produces, project.trb says what the names cannot, and a program is the one thing it says by hand. A file called lib.trb is the library, a file called main.trb is the program, a file called *.test.trb is a test — and none of that needs a line in a manifest. What is left for the manifest is what no file name can carry: who the package is, what it needs, where each of those comes from, and the rare second program, which is configured rather than found by a path convention. build { input } and test { input } say the first half a second time, in a weaker language, and they go.

  project.trb ──→ name, version, dependencies, where each one comes from, the workspace, the profiles,
                  and the second program:   program "migrate", entry: "tools/migrate.trb"

  src/  ──┬── lib.trb ─────────────→ the library, imported as "owner/name"
          ├── <path>.trb ──────────→ a module of it, imported as "owner/name/<path>"
          └── main.trb ────────────→ the one program that needs no line in the manifest
  tests/ ─── *.test.trb ───────────→ the test entries
                                     what no manifest should say: WHAT the package produces

This design replaces four things that exist today: Build { target, input, output } and Test { input } in std/project/src/lib.trb, the buildInput/testInput fields of compiler/src/project/manifest.trb and the two places that read them (compiler/src/cli/build.trb, compiler/src/semantics/graph.trb), the thirty-two build { input = "src/lib.trb" } blocks that the standard library writes to say "this is a library", and the sentence in docs/tooling/project-trb.md that lists which settings are read. It adds three things the language does not have: a grammar for the text after from, a program setting, and a toolchain that is the caller of the receiver script it reads.

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

Every claim about what the toolchain does today comes from a probe in section 1, run on a stage 0 built in the worktree that wrote this. A claim the probes do not cover is marked as unproven where it stands.


1. What the toolchain does today#

Twenty-one probe packages, each a directory with a project.trb whose workspace reaches the standard library, checked and built with the self-hosted compiler on stage 0 (torb run ../compiler check <package>, torb run ../compiler build --emit-c <package>).

#The packageResult
1src/main.trb and src/other.trb, both with top-level code3 files, no problems
2src/main.trb plus src/server/main.trb, both with top-level code3 files, no problems
3src/main.trb and src/lib.trb in one package3 files, no problems
4build { input = "src/other.trb" } next to a src/main.trb3 files, no problems; the build emits #line 1 "probe/other-input/src/other.trb"
5build { input = "src/server/main.trb" }the build emits #line 1 "probe/second-program/src/server/main.trb"
6no build { } at all, one src/main.trb2 files, no problems; the build writes build/release/program.c
7only src/lib.trb, no build { }check passes; build: error: `torb build` needs one entry file. Name the file, or a project with a `build` input
8use File from "std/fs" with an empty dependencies2 files, no problems
9use greet from "probe/lib" where probe/lib is a workspace member and not a dependencyerror: The package `probe/lib` is not a dependency of `probe/app`
10the same with dependencies { runtime "probe/lib" }5 files, no problems
11dependencies { runtime "acme/http:^1.2.3" } for a package that exists nowhere, never imported2 files, no problems
12name = "probe-bare" — no owner2 files, no problems
13use X from "https://example.test/x"error: There is no package `https:/`
14use X from "github.com/project/x"error: There is no package `github.com/project`
15use logo from "./logo.png" next to a real logo.pngerror: There is no module `./logo.png`
16use logo from "./logo.png" next to a file named logo.png.trb3 files, no problems — it resolves
17use marker from "probe/lib/main" — another package's src/main.trb, which has top-level code6 files, no problems — it resolves
18build { input = "src/lib.trb" } on a library whose src/lib.trb has top-level code5 files, no problems; without the build line the same file is error: Top-level code is only allowed in entry files
19a parameter of a user type, called with a string literalerror: Expected `Resource`, found `String` — a literal adapts to nothing today
20Sandbox.load<ServerConfig>("./nowhere/at/all.trb"), and the same with a String variable2 files, no problems — the checker reads the path not at all
21the same program on stage 0 and through the native back endstage 0: error: Unknown name `Sandbox` ; native: error: the type `Script` is not supported by the native back end yet

The two diagnostics in full, because their notes are what a user reads:

error: There is no package `https:/`
 --> src/main.trb:1:1
  |
1 | use X from "https://example.test/x"
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = A package is `owner/name`. The standard library comes with the toolchain; everything else has to be a
    dependency or a member of the workspace
error: There is no module `./logo.png`
 --> src/main.trb:1:1
  |
1 | use logo from "./logo.png"
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^
  = A path that starts with `./` or `../` is a file next to this one, without its extension

Two further facts, each from a probe rather than from reading the code:

  • A file outside src/ is not a module of the package. use helper from "../tools/helper" in src/main.trb, with a real tools/helper.trb, answers error: There is no module `../tools/helper` . The package's modules are src/** plus whatever test { input } names, and nothing else. Fixed 2026-09-27 by section 3's rule: a package's files are every .trb file below its directory.
  • A workspace member pattern is relative to the root that writes it, and a pattern that reaches far enough out of the tree stops matching. members "<absolute path>/std/*" answers error: The workspace member pattern ... matches no project, and so does the same path written with eleven ../. The patterns in the repository (std/*, ../../../std/*) work; the general case is not something the probes could make work.

Probes 19 to 21 are the ones this round added, and together they say what the two new mechanisms cost. Probe 19 is the baseline for section 9 and for docs/design/RESOURCES.md: a string literal adapts to nothing, so a parameter type the compiler resolves is checker work that does not exist in any form yet. Probes 20 and 21 are the baseline for section 8: Sandbox.load type checks perfectly — the generic argument, the trailing capability block, a path that is a computed variable, a path that names no file — and then runs on neither implementation. The sandbox is a signature today and nothing else, which is why section 8's slice is a 7.x slice.

Four places where the docs promise more than the code does#

  1. "Nothing outside a package can name its src/main.trb." docs/language/modules-and-packages/packages.md rule 2. Probe 17 imports one. Worse, that src/main.trb is an entry file by the manifest's own rule, so it may hold top-level code and be imported — the one combination the language forbids, reachable without a diagnostic. Fixed 2026-09-27: "owner/name/main" and every import of a program's entry are errors (section 3).
  2. "Top-level code is only allowed in entry files." Probe 18: build { input = "src/lib.trb" } — which is what thirty-two of the repository's thirty-five project.trb files write — makes src/lib.trb an entry file and switches the rule off for the one file it exists to protect. The two rules are written in two places that do not agree: isEntryFile asks the manifest, isLibraryModule hardcodes src/lib.trb, and isEntryFile is asked first. Fixed 2026-09-27: the names of the files decide, src/lib.trb is never an entry, and removing the thirty-two blocks found no top-level code in any library.
  3. "A name is owner/name." packages.md rule 1 and std/project's own doc comment. Probe 12: a bare name is accepted everywhere, and the static reader has no validation of any kind.
  4. build { target, output } and test { input }. torb build hardcodes the profile name "release" and the path <project>/build/<profile>/<name>; torb test hardcodes the directory tests. output is read by nobody — compiler/project.trb writes output = "build/{target}/torb" and the binary is not called torb. An interpolated string is not a literal, so the static reader cannot read it at all, and it ignores it silently. Fixed 2026-09-27: both blocks are gone, a binary goes to build/<profile>/<program> or to the literal output of its program line, and torb test tests the package it is run in.

Every one of the four is a consequence of the same thing: a manifest setting that says what a file name already says, and then disagrees with it. The fourth is also the shape of the one setting this design adds: program says what no file name says, and section 4 is careful that it can never disagree with the tree, because the tree has nothing to say about it.

2. What other systems do#

SystemSeveral programs in one packageOne library or manyWhere versions and locations liveHow a non-code file gets inWhat the lock pins
Cargosrc/main.rs, src/bin/*.rs, [[bin]] tablesexactly one [lib]Cargo.toml; git/path/registry written on the dependency lineinclude_bytes!/include_str!, relative to the file; nothing lists themname, version, source, checksum, per package
Go modulesevery directory whose package is main, by convention cmd/<name>/many — every directory is an importable packagethe import path is the location, host-qualified; go.mod lists modules and versions//go:embed on a variable, patterns relative to the file's directorygo.sum: a hash per module zip and per go.mod
Denoany file with top-level code; named tasks in deno.jsonnot a package model at allonce: the URL in the import. Now: deno.json's imports map and jsr:/npm: bare specifiersan import attribute (with { type: "text" }), or a run-time read under a process permissiondeno.lock: an integrity hash per specifier
JSR—one package with an exports map of several entry pointsdeno.json/jsr.json, resolved through the import map——
Node, bundlersa bin map in package.jsonone package, an exports mappackage.json, plus npm:/file:/git: protocols inside the version fielda loader chosen by file extension, configured in the bundlername, version, resolved URL, integrity
SwiftPMproducts: [.executable(...)], each over targetsany number of library productsPackage.swift, which is a Swift programresources: per target, copied or processed, reached through Bundle.modulePackage.resolved: a revision per package
Zigone b.addExecutable per program in build.zigb.addModulebuild.zig.zon: a URL and a hash@embedFile, relative to the filethe hashes in build.zig.zon
Dart, pubbin/<name>.dartone lib/, with lib/src/ private by conventionpubspec.yaml; git:/path: on the dependencyan assets: list in pubspec.yamlpubspec.lock
Gleamsrc/<name>.gleam with a pub fn mainone src/gleam.toml and a generated manifest.tomlfiles under priv/, copiedhashes in manifest.toml

What we take.

  • From Cargo: one library per package, a manifest with a closed vocabulary, and a lock file that pins a content hash rather than a version. All three are already CONCEPT's position. And, against the previous round of this document, the [[bin]] table — for the second program only, and stripped to three settings that are all literals.
  • From Go: a host-qualified name that the host verifies instead of a registry, which is section 7's github.com/project/x as a name. Not its "a program is a directory with a main", which is what section 4 weighed and dropped.
  • From Dart and Gleam: the convention decides which file is the program, and the manifest stays small enough to read in one breath. Gleam's gleam.toml is the size a project.trb should be.
  • From Zig: @embedFile's shape — a file named by a literal that the compiler resolves. That is docs/design/RESOURCES.md, minus the part of Zig we must not take.
  • From Deno: the conclusion, not the mechanism. Deno shipped URL imports in source and then built an import map to get the locations back out of the source files. Taking the conclusion without repeating the experiment is the whole of section 7.
  • From SwiftPM: resources: states the honest requirement — the build must know every non-code file a program needs. We answer it from the call sites instead of from a second list, and section 9 says what the build does with the answer.

What we leave.

  • bin maps, products:, and [[bin]] for the first program. A list in the manifest of things the directory already shows is a second source of truth, and build { input } is that mistake one program small. src/main.trb needs no line, and that is the case that is not rare.
  • Package.swift and build.zig — a manifest that is a program you run, with the machine's whole surface under it. A project.trb is a receiver script that the toolchain evaluates under one capability (section 8), which is a different thing: it can read a file below its own directory and it can do nothing else at all. That line is the one this design must not cross, and section 8 draws it exactly.
  • An exports map (JSR, Node). "owner/name/path" already is the surface, and public already decides what a module exports. A second list would be a second source of truth about the same thing, which is the disease of section 1.
  • Loaders chosen by extension (Node, bundlers). docs/design/RESOURCES.md says why.
  • Cargo features and optional dependencies. CONCEPT already rejected them: an optional integration is a package.
  • git/path/npm: written on the dependency line. We take the information and split it into two statements, so that "what I need" and "where it comes from" are two lines a reviewer can read separately (section 7).

3. What a package produces#

A package produces at most one library and any number of programs. The library and the first program are decided by the name of a file; every program after the first is a line in project.trb.

  • src/lib.trb makes the package a library. It is what "owner/name" resolves to, and "owner/name/path" is src/path.trb of it. There is no second library, because the package name is the import name: a second library would need a second name, a name is a package, and a second package in the same tree is a workspace member. This is Cargo's answer and not Go's, and the reason to prefer it here is that TorbScript already has the finer surface — "owner/name/path" — so the thing many-libraries buys is already bought.
  • src/main.trb is the program, named after the package, and it needs no line in the manifest.
  • Every further program is a program line (section 4). There is no path convention for a second program: a convention would reserve a directory name or a file name in every package forever, to buy a shorthand for something that is rare.
  • A file called *.test.trb is a test entry, anywhere in the package.
  • Every other .trb file under src/ is a module: no top-level code, importable inside the package, importable from outside when the package is a dependency.

A file that may hold top-level code is never importable, and a file that is importable may never hold top-level code. That is one rule read in two directions:

The fileTop-level codeImportable
src/lib.trbnoyes, as "owner/name"
src/<path>.trbnoyes, as "owner/name/<path>"
src/main.trbyesno
the entry of a program lineyesno
*.test.trbyesno
a receiver script (project.trb, anything the sandbox loads)yesno
a .trb file with no project.trb above ityesit is a script; there is nothing to import it

The sixth row is already true. The third row's "no" is new and it closes probe 17: "owner/name/main" becomes an error, and so does any package path whose last component is main. The fourth row is what makes entry a plain string literal and not a computed one: the checker has to know which files are entry files before it checks any of them, and the static reader (section 10) is what tells it. Section 4 says what happens when the two collide anyway.

The one rule that is not local stays. CONCEPT says top-level code is about being imported, not about the file name, and the checker implements the fallback: a file nothing imports may hold top-level code. That fallback is what makes the thirteen chapters of examples/tour/src/ and the seventy-three conformance programs of tests/conformance/ legal, and neither of them is a product — they are scripts that happen to live in a package. Keeping the fallback costs a whole-workspace analysis (isImported resolves every use of every module before it can answer about one file), which an editor cannot do incrementally and which makes a file become illegal because of an edit somewhere else. The trade is worth it, because the alternative is seventy-three directories, and because with the semantic names the fallback is now only ever consulted for files that are not products. What changes is that it is written down as what it is: a script is a file, not a product. torb build with no argument never builds one, and no program line names one.

build { input } and test { input } disappear. Nothing is left for them to say. What they do say today is wrong in three of the four ways section 1 lists, and their one real use — tests/conformance/project.trb's test { input = "." }, which pulls flat .trb files into the package — is served better by widening what belongs to a package:

A package's files are every .trb file below its directory that is not inside a nested package, skipping hidden directories, target, node_modules and build. That is one rule instead of two paths from the manifest, it makes ../tools/helper resolve (the second probe fact of section 1, which is a surprise today), and it is what a reader already assumes when they say "the project". It is also what lets a program name an entry outside src/: tools/migrate.trb is a file of the package, so it may import the package's modules like any other file of it.

As built (2026-09-27). packageAt of compiler/src/project/workspace.trb takes every .trb file below the package directory that no nested project.trb claims, skipping hidden directories, build, target and node_modules; project.trb and project.lock.trb are never files of it. isEntryFile of compiler/src/semantics/graph.trb reads the file name and the program lines and nothing else, so src/lib.trb of a named package holds declarations only, always. torb test without a path tests the package the working directory is in: every *.test.trb below the innermost directory above it with a project.trb.

4. Programs#

A program is src/main.trb, or a program line. One setting, three arguments, all of them plain string literals.

program "migrate", entry: "tools/migrate.trb"
program "importer", entry: "tools/importer.trb", output: "dist/importer"

and, for the one case that needs no entry at all:

program "torb"

which renames the default program: src/main.trb still is the entry, and the binary is called torb instead of being named after the package. That is torbscript/compiler's whole manifest line, and it is why compiler/src/main.trb does not move anywhere.

The signature, and why there is no trailing block#

/** A program of this package, beyond the `src/main.trb` that needs no line. */
public type Program {
  /** The name of the program, of the binary, and of the argument to `torb run` and `torb build`. */
  name: String
  /** The file whose top-level code the program is. Empty renames the default program `src/main.trb`. */
  entry: String = ""
  /** Where the binary goes, relative to the project and taken literally. Empty means `build/<profile>/<name>`. */
  output: String = ""
}
extend Project {
  /** Declares a program: `program "migrate", entry: "tools/migrate.trb"`. */
  var fn program(name: String, entry: String = "", output: String = "") {
    programs.append Program(name, entry, output)
  }
}

A method with labels, not a method with a receiver closure. The two forms already exist side by side in the vocabulary and the line between them is not style: registry "acme", url: "..." is labels, profile "release" { ... } is a block, and the difference is whether the toolchain has to read the setting before it can run anything. A receiver closure is evaluated — its body is statements, its arguments may be computed, and the static reader of section 10 cannot see into it at all. All three of a program's settings are in the static nine: entry decides which files may hold top-level code, so the checker needs it; name is what torb run <name> matches; output is a path the build writes and torb publish has to be able to list. None of the three may be computed, so none of the three may live in a block.

The block is what the design reserves for the setting that is genuinely not static. If a program ever needs a per-program profile override, or a target it refuses to build for, that is a program "migrate" { ... } block next to the three labels, and the three labels stay on the line where the static reader can see them. Adding it later costs one optional parameter of the function type (var self: Program) => Void with the default {}, exactly the way Sandbox.load carries its capabilities, and it breaks nothing written before it. Adding it now would cost a block that has nothing to put in it and an invitation to put the static three inside it.

The rules, and the diagnostic for each#

The mistakeWhere it is caughtThe message
two program lines with one namethe static reader, before anything is checkederror: There are two programs called `migrate` with both lines
a program whose name is the package's own short name and which is not the defaultthe static readerallowed — program "shop", entry: "tools/shop.trb" in acme/shop is only a collision if src/main.trb also exists, and then it is the "two programs" error above
two program lines with no entrythe static readererror: `torb` and `cli` both rename the default program
program "torb" with no src/main.trbthe workspace reader, once it has the treeerror: `torb` renames the default program, and there is no `src/main.trb` with the note that a program with an entry writes entry:
an entry that does not existthe workspace readererror: The entry `tools/migrate.trb` of the program `migrate` is not a file
an entry outside the package directorythe workspace readererror: The entry `../tools/migrate.trb` of the program `migrate` is outside `acme/shop`
entry: "src/lib.trb"the workspace readererror: `src/lib.trb` is the library of `acme/shop`, so it cannot be the entry of a program
an entry that some module importsthe module graph, at the useerror: `../tools/migrate` is the entry of the program `migrate`, so nothing can import it
two programs that resolve to one output paththe build, per profileerror: `migrate` and `importer` both build to `dist/tool`
an output that is interpolatedthe static readerthe plain-string rule of section 10, with its own message

The entry-that-is-imported rule needs two diagnostics for one rule, and that is not an accident. The manifest can see entry: "src/lib.trb" — src/lib.trb is a file whose role the name decides, so the manifest line is wrong on its own and the message points at the manifest. The manifest cannot see that somebody, three files away, wrote use helper from "../tools/migrate"; only the module graph can, and the message that helps is the one at the import. So: a collision with a name-decided role is a manifest error, and a collision with an actual import is an import error. Both quote the program line.

A program name is a segment of section 6's grammar: lowercase, digits and -, no dot, no slash. That makes torb run <argument> decidable without touching the disk: an argument that contains / or \, ends in .trb, or is . or .., is a path; everything else is a program name. torb run migrate is a name, torb run tools/migrate.trb is a file, torb run . is a directory, and there is no case where the two readings are both possible. The price is that a directory named without a slash is a name too: torb build compiler looks for a program called compiler, and the directory is written ./compiler.

What run and build do#

torb run [name] [arguments]     run the only program, or the one named
torb run <file.trb>             run a script or an entry file directly
torb build [name]               build every program of the package, or the one named
torb build --output <file>      one program, somewhere else
  • torb run with no name runs the package's only program. "Only" counts src/main.trb plus every program line that has an entry; a program line without an entry is the same program under another name and does not add to the count. Several programs and no name is an error that lists them:

    error: acme/shop has 3 programs. Name one: `torb run shop`
      = shop, importer, migrate
  • torb run <name> runs that program. A name nothing declares lists the ones that exist.

  • torb build builds every program of the package, in the order the manifest declares them with src/main.trb first. In a workspace at the root it builds every program of every member.

  • torb build <name> builds one.

  • A library-only package builds nothing, and that is not an error. Today it is (probe 7). There is no artifact a library produces on its own — the C back end emits one translation unit per program — so torb build on std/text checks it and says acme/lib is a library: checked, nothing to build. That matters most in a workspace, where torb build at the root walks thirty members of which twenty-six are libraries; twenty-six failures would make the command useless at the one place it is worth having.

  • torb run <file.trb> and torb build <file.trb> name a file directly, which is how a script is run and how a program that is not a product of any package is built. Nothing here changes that, and it is what keeps examples/tour and tests/conformance/ legal: they declare no program at all, so they have no products, and torb run ../compiler check .. still checks every one of their files as the script it is (section 3's fallback).

What the thirty-two manifests become#

Every one of the thirty-two libraries that writes

build {
  input = "src/lib.trb"
}

deletes the block and writes nothing in its place. src/lib.trb is what makes it a library, and probe 18 says the block was never doing what its author thought: it made src/lib.trb an entry file, which switched the top-level code rule off for the one file it exists to protect.

compiler/project.trb becomes three lines and one of them is new:

name = "torbscript/compiler"
version = "0.1.0"

program "torb"

tests/conformance/project.trb and examples/tour/project.trb lose test { input = "." } and build { } respectively, and gain nothing: section 3's "a package is its whole directory" covers what test { input = "." } was for.

This is the decision that removes a whole slice from the migration. The previous round's recommendation — a program is src/<name>/main.trb, so the compiler's binary is compiler/src/torb/main.trb — moved the compiler's own entry file while the compiler was compiling itself, and gave nineteen relative imports one more ../ each. A configured program costs one line in one manifest and moves no file.

Programs as built (2026-09-27)#

Program and Project.program are in std/project with the signature above; compiler/src/project/programs.trb lists the programs of a package (programsOf) and reports the rules of the table (programProblemsOf), each as an error of torb check with --> project.trb:<line>:<column>. A computed name, entry or output is refused by the static reader with The name, the `entry` and the `output` of a `program` have to be plain strings and the note The toolchain reads them before it can run anything: the entry decides which files may hold top-level code. Two rules came in beside the table's: `Migrate` is no program name (a program is named like a package, so torb run <name> is never a path) and The output `../dist/x` of the program `x` is outside `acme/shop` - a build writes below its project and nowhere else. The module graph refuses ./main, a relative path to any entry, and a package path whose last component is main, with the note A file that may hold top-level code is never importable. What two programs share belongs in a module of the package, which both import.

  • torb run without an argument runs the only program below the working directory. torb run migrate looks the name up among the programs below it; a name nothing declares answers error: There is no program `nothing` here, = the programs are: shop, migrate, and = a directory or a file is written as a path: `./nothing`. A library answers error: acme/lib is a library: there is nothing to run, and a package of scripts - neither src/main.trb nor src/lib.trb - error: acme/tour has no program: there is nothing to run. Both in the VM and with --native.
  • torb build builds every program whose entry lies below the path (default .): per package src/main.trb first, then the program lines in order, and at a workspace root every program of every member. Where there is none it checks and says why, and leaves with 0: acme/lib is a library: checked, nothing to build, acme/tour has no program: checked, nothing to build, or for several packages no package below <path> has a program: checked, nothing to build. --output and --emit-c are for one program: with several the "Name one" error comes with `--output` is for one program. Two programs that resolve to one path answer error: `migrate` and `importer` both build to `dist/tool` .
  • A file named directly builds and runs as before. Where it is the entry of a program, it keeps that program's name and output.
  • compiler/project.trb says program "torb", and the thirty-two library manifests say nothing.

5. Profiles and targets#

build { target = "release" } puts two different things under one word. They separate:

  • A profile is how the same program is built: optimisation, debug information, whether a panic prints frames, whether integer overflow checks are elided (they are not — overflow panics in every profile, that is a rule of the language and not a setting). The profiles are dev and release, there is no third, and a project may set the few knobs each one has.
  • A target is the machine the program is built for: an operating system and an architecture, and later wasm and the VM. It is not a project setting at all. A package that only works on one target says so by failing to compile there, which is what native and foreign already make visible.
torb build                          release, the default of build (decided 2026-09-27, below)
torb build --profile dev            the profile torb run --native and torb test build by default
torb build --release                the shorthand for --profile release
torb build --target linux-x64       cross-compile; the default is the host
profile "release" {
  optimize = 2
  debugInformation = false
  panicFrames = false
}

The first round of this design made dev the default of torb build, Cargo's and Zig's default: the first build somebody runs should be fast and its panics should carry frames.

Decided when it was built (2026-09-27): torb build keeps release as its default, and torb run --native and torb test keep dev. The argument for dev is served already, by a different command: the first thing a newcomer runs is torb run, which compiles nothing at all, and the two commands that build to be run once - torb run --native and torb test - build dev. What is left for torb build is the build somebody asks for by name, and that is the one that is shipped, measured or installed, so it builds what ships. The repository relies on it besides: tools/bootstrap.sh, the gates and CLAUDE.md name build/release/torb, and a default that moved would move every binary they name. --profile dev is one flag for the other case.

profile is a method on Project taking a name and a receiver closure, so the vocabulary does not grow a field per profile and a fourth profile name is a diagnostic rather than a parse error. A setting a back end does not have yet is read and ignored — that is the one place in this design where a silently-ignored setting is right, because the back end is the thing that is incomplete, not the file.

Where the binary lands. build/<profile>/<program> for the host, and build/<target>/<profile>/<program> when --target names something else. That is Cargo's layout exactly, for Cargo's reason: two targets must not overwrite each other and the common path must stay short. A program line's output replaces the whole path for that one program and is taken literally, relative to the project directory: output: "dist/migrate" is dist/migrate under every profile and every target. --output <file> overrides even that, for one program and one invocation.

output on a program is literal, and build { output } is deleted. Today's output = "build/{target}/torb" is a TorbScript string interpolated eagerly against the receiver, so it is evaluated once, when the file runs, with whatever target was at that moment — which means it cannot express "per profile" at all without the file being evaluated once per profile. The static reader cannot read it either: an interpolated string is not a literal, and today it is ignored without a word. The replacement says the two useful things separately: the default path carries the profile and the target because it is a convention, and an output that overrides it is a fixed place somebody wants a file, which is a literal by definition. A project that wants one program under two profiles in two places wants --output, which is an argument of the invocation that knows which profile it is.

As built (2026-09-27). std/project has Profile { name, var optimize: Int, var debugInformation: Bool = false, var panicFrames: Bool } with static fn named(name: String): Self - optimize 1 and panicFrames true in dev, 2 and false in release - and Project.profile(name: String, configure: (var self: Profile) => Void). The build passes -O<optimize> (MSVC: /Od, /O1, /O2) and -g where debugInformation is true. panicFrames is the macro TORB_PANIC_FRAMES (1 or 0) for the C compiler, of the program and of a script's host alike, and it alone decides whether a native panic prints the frames below its site: profile "dev" { panicFrames = false } makes a dev binary print the site alone, profile "release" { panicFrames = true } gives a release binary the frames. The C is the same under every profile and setting. An MSVC build keeps no frames whatever the block says, and the VM prints the site alone (docs/design/PANICS.md). A member without a block of a name takes its workspace root's. A literal block is read statically; a computed one makes the toolchain evaluate the manifest in the sandbox, as a computed version does. The problems, each at its block: `fast` is no profile: the profiles are `dev` and `release`, There are two `profile "dev"` blocks and `optimize = 4` of `profile "dev"` is outside 0 to 3. The flags --profile, --release and --target were there before and did not change, and a --target that is not this machine still needs --emit-c. The binary lands where this section says, with program.c beside it.

6. The specifier grammar#

Today the text after from is a string the resolver takes apart with startsWith("./") and split("/"), and everything that is not one of the two shapes falls into "there is no package <the first two segments>". That is how https://example.test/x becomes There is no package `https:/` . A grammar makes the good shapes precise and gives every other shape a message of its own.

specifier   = relative | package | scheme
relative    = ("./" | "../") component ("/" component)*
package     = owner "/" name ("/" component)*
scheme      = word ":" rest

owner       = segment | segment ("." segment)+          a dot makes it host-qualified: RESERVED
name        = segment
component   = segment
segment     = lowercase (lowercase | digit | "-")*      no dot, no "." and no ".."
word        = lowercase (lowercase | digit)*
  • A relative specifier is file-relative and names a module without its extension, which is what the note already says. component forbids a dot, so "./logo.png" is a grammar error with its own message instead of resolving to src/logo.png.trb (probe 16) or failing as a missing module (probe 15). No module in the repository has a dot in its name, and reserving the dot is what keeps use logo from "./logo.png" unavailable — which docs/design/RESOURCES.md lists as a non-goal and not as a gap.
  • A relative specifier may leave src/, because section 3 widened a package to its whole directory. It may not leave the package: a ../ that climbs past the package directory is an error naming the package. As built (2026-09-27), so is a relative path to a file of a nested package, which is another package: error: `../../linear/src/vector2` leaves `std/geometry` , with the note A relative path names a file of the same package. Another package is imported by its name: `"owner/name/path"`, and it has to be a dependency. std/geometry and five conformance programs imported their neighbours that way and now write "std/linear/vector2".
  • A package specifier is owner/name, optionally followed by a module path. The path is resolved under src/, it may not contain . or .., and its last component may not be main — a program is not importable (section 3).
  • An owner that contains a dot is host-qualified and is reserved. github.com/project/x parses as the name it looks like and answers a message that says what it would mean, rather than There is no package `github.com/project` . Section 7 says what it would take to make it resolve.
  • A scheme: prefix is reserved, all of it. file:, http:, https:, git:, npm:, jsr: and anything else of the shape word:. Nothing resolves one today and the message says why, not that the package is missing:
error: A module specifier cannot be a URL
 --> src/main.trb:1:1
  |
1 | use X from "https://git.acme.test/project/x"
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  = Source files name packages; where a package comes from belongs in project.trb:
    `dependencies { runtime "acme/x" }` and `source "acme/x", git: "https://git.acme.test/project/x"`

One grammar, five readers, and each takes a subset. This is the part that has to be written down, because the five look alike and are not the same language:

WhereWhat it acceptsRelative to
use ... from "..."relative and packagethe file
dependencies { runtime "..." }owner/name, optionally followed by ":" requirement — never a module pathnothing
source "...", ...the same owner/namenothing
program "...", entry: "...", output: "..."a segment for the name; a path for entry and output, with the extension written and no ..the project
a resource literal (docs/design/RESOURCES.md)a path, not a module specifier: ./ or ../, with the extension writtenthe file at compile time, recorded as project-relative

The last two rows are the ones that surprise, and they differ from each other on purpose. program's entry is written in project.trb, which is the project directory, so it is project-relative and has nothing else it could be. A resource literal is written in a source file that may belong to a library somebody else wrote, so it is file-relative, and the project-relative name the run time needs is what the compiler records rather than what the author writes. use Config from "./config" names a module and drops the .trb; a resource path names a file and writes it. They are different questions — one is about the source tree, the other about a file the program carries — and they stay different.

6a. Requirements and resolution#

Built (2026-09-26), compiler/src/package/. The package manager's resolver is pure TorbScript over a fixture of index files in compiler/tests/packages.test.trb, and the whole cycle runs against a file: registry in tools/packages.sh, a gate of tier A.

The requirement grammar - the text after the : of a dependencies line - is Cargo's, decided here because section 6 left it open:

requirement = "*" | comparator (" " comparator)*          every comparator holds
comparator  = ["^" | "~" | "=" | ">=" | ">" | "<=" | "<"] partial
partial     = number ["." number ["." number ["-" pre-release]]]
  • A comparator without an operator is a caret, and a line without : allows every release: ^1.2.3 is >=1.2.3 <2.0.0, ^0.2.3 is >=0.2.3 <0.3.0, ^0.0.3 is 0.0.3 alone, ~1.2.3 is >=1.2.3 <1.3.0, and a partial version stands for the range it leaves open (=1.2 is >=1.2.0 <1.3.0). A : with nothing after it is an error.
  • A pre-release is only chosen where a requirement names one, and then only pre-releases of that one release (^1.2.3-beta.1 allows 1.2.3-beta.2 and every release up to 2.0.0, and not 1.3.0-beta.1). A version set is therefore two halves - releases and pre-releases - each a list of half-open intervals, so that complement, intersection and union stay exact and the resolver's algebra holds. A version is semver 2.0.0 without build metadata: two releases that differ only in +build would be one version to the resolver and two to a person.

The algorithm is PubGrub (Natalie Weizenbaum, 2018: Dart's pub, uv, and pubgrub-rs), chosen over minimal version selection and over plain backtracking:

CandidateForAgainst
PubGrub — decidedthe highest compatible version, which CONCEPT asks for; it learns an incompatibility from every conflict, so it never walks into the same dead end twice; and the learned incompatibilities are the derivation of a failure, which is written out as a numbered chain of "because" sentencesthe most code of the three, and the error writer is half of it
Minimal version selection (Go)no solver at all; reproducible without a lockpicks the lowest version a requirement allows, which contradicts CONCEPT's "the highest compatible version"; an explanation is a list of requirements, not a reason
Backtracking (Cargo's, historically)smallexponential in the worst case, and "no solution" after trying everything is what users of every other package manager learn to fear

Deterministic: the next package is the one with the fewest allowed versions, ties by name; the version is the highest allowed - or the one the lock already has, where the constraints still allow it, which is how torb add, torb remove and torb update <name> keep every other package where it was. A release the index yanks, or one that needs a newer language than the toolchain's, is not chosen, unless the lock already pins it (docs/design/RELEASE.md section 7.3), and the explanation names it when it is why a range is empty.

Workspaces: the root depends on every member, and every member and every path: package is a package of one version - its manifest's, 0.0.0 where it says none - whose requirements are its runtime and development lines (only runtime for a path: package and for a registry package). Two members that want what no version satisfies are explained through both.

One version per package where one does, one per major where only that does (resolver and lock built 2026-10-12, issue #323). CONCEPT allows two majors of one package side by side. Resolution runs once with one version per package, so every graph that resolved before resolves, and locks, to the same bytes; only where that has no solution does it run a second time, with the resolver's package (name, major) for every package whose candidates span several majors (acme/json 1.x, acme/json 2.x). A requirement that reaches into several majors (*, >=1.0.0) is one incompatibility with a negative term per major, {depender, not acme/json 1.x, not acme/json 2.x}: it holds as soon as one major in the graph satisfies it, and where none does yet the resolver decides the highest major, learning its way down to a lower one where the highest fails. A requirement that reaches no major with a release is a major of its own with no versions. The second run's answer is the answer, and its explanation names the major where the package is split (acme/json 1.x ^1.2.0). The lock has a package line per major, sorted by name and then version.

Two majors of one package, in the checker and in a binary (checked 2026-10-13, built 2026-10-14). A package is identified by its name and its version, and a workspace keeps one package per locked entry, so the two majors of acme/json are two packages with a module graph and a type table each: a Value of 1.x and a Value of 2.x are two types without any further rule. use "acme/json" is resolved for the importer (Workspace.dependencyOf): the one package of the name, or, where two majors are here, the highest version the importer's own requirement allows - for a requirement that spans both (>=1 <3) the higher one - and the first where it writes none. A message names the major, acme/json 2.x, only where two are here, and a mismatch between the two reads Expected `acme/json 2.x Value`, found `acme/json 1.x Value` ; two types of one name from one major still print bare.

Lowering and the back ends keep the two apart by the same rule. Workspace.identityOf(package) is the token every name and path is derived from: the bare name where the workspace holds one package of that name, name@major for each where it holds several (acme/json@1, acme/json@2; a version that does not parse falls back to the bare name). It feeds the symbol path of a declaration (so the mangled names of the two majors differ, t_acme_x2f_json_x40_1_... against t_acme_x2f_json_x40_2_...), the names of a package's script and entry functions, the stable paths of the modules (the paths in debug information, coverage and torb doc), the qualified names of the layout text, and the package names of the reference model. The std/* packages are looked up by their bare name and are never split. The granularity is the workspace, not the program: a workspace with one package of every name, the compiler's own included, produces byte-identical output, and only a workspace that holds two majors sees @ in a name. torb build, torb run, torb doc and torb test accept a program that reaches both.

Fetching the index first. The resolver is pure, so every index a resolution may need is fetched before it runs: the index of every package any version of what is reached depends on. That over-fetches old versions' dependencies, and an index that is not there is a package with no versions, which the explanation names.

7. Where a dependency comes from#

Decided: source files name packages, project.trb says where each package comes from, and project.lock.trb pins what was actually taken. A host-qualified owner is available as a name. The alternative — a location in the use line — is written out here with its cost, because it is what was asked for first and because nobody should have to reconstruct the argument.

(A) Names in source, locations in the manifest — this is the decision.

dependencies {
  runtime "acme/x:^1.2.3"
}
source "acme/x", git: "https://git.acme.test/project/x", revision: "v1.2.3"
use X from "acme/x"

(B) The location in the source file: use X from "https://git.acme.test/project/x".

(C) A host-qualified name — Go's model: use X from "github.com/project/x", where github.com/project is an owner whose namespace the host verifies instead of a registry, and dependencies still lists the requirement centrally.

(A) locations in the manifest(B) locations in source(C) host-qualified names
Reproducibilityone requirement per package, in one filetwo files can name two versions of one package; nothing makes them agreeone requirement per package, in one file
Reviewing an updateone diff in project.trb and project.lock.trba grep across every source filethe same as (A)
Offline buildsthe lock plus a cache answers it"what do I have to vendor" is a source-wide querythe same as (A)
A fetch at compile timenever: resolution reads the lockthe checker resolves a URL, so the type checker needs the networknever
Where the location is visiblein one file, next to the requirementon the import line, where it is usedin the name, everywhere it is used
Namespace trustthe registry binding, which is what stops dependency confusionwhoever holds the host namethe host, which is a weaker promise than a registry but a real one
Sandbox capabilitiesunaffected — they are granted at the call siteunaffectedunaffected

The decisive argument against (B) is not reproducibility, it is the fetch. use is resolved by the checker. If a specifier can be a location, then type checking a file can open a socket. That breaks three things CONCEPT says in three different places: "Installing never runs code", "project.trb is evaluated in a sandbox without IO, so reading the metadata of a package is safe", and "the compiler knows from the imports alone what a package can touch". The last one is the worst: the promise is that a program's capabilities are readable from its imports, and a compile-time fetch adds a capability to the compiler that no import declares. Deno is the experiment that was already run — URL imports in source, then an import map in deno.json and jsr:/npm: bare specifiers to get the locations back out of the files. Taking the result without repeating the experiment costs nothing.

What (B) buys and (A) loses, honestly: you can read where a dependency comes from on the line that uses it, and a one-file program can depend on something without a second file. Both are real. The first is answered by the fact that a project has one project.trb and a reader opens it once; the second is answered below.

A script has no project file, and does not get dependencies through the back door. A script gets the standard library and nothing else. The moment it needs a dependency it needs a project, which is one torb init away. Naming the requirement in the use line would be (B) in miniature, with the same fetch during torb run, and it would create the split Deno had — one rule inside a project, another outside it. If that is ever too strict, the spelling should be a manifest in the file and not a location in a use — a project { } header at the top of the script, in the same vocabulary, which is what Python's inline script metadata settled on and what keeps one rule for where a requirement is written.

Sources, spelled out. One source line per package, and a package with none comes from the registry its owner is bound to:

registry "acme", url: "https://packages.acme.test"

source "acme/x", git: "https://git.acme.test/project/x", revision: "v1.2.3"
source "acme/y", path: "../y"
source "acme/z", archive: "https://files.acme.test/z-1.0.tar.gz", hash: "..."

A source overrides where a name resolves and never what it is called, so replacing a registry package with a local checkout during development touches one line and no source file. revision is required for git: — a branch name is not a version — and hash is required for archive:, because there is no registry to ask.

As built (2026-09-26): registry and source are read statically and printed back by Project.settings(), and path: sources are resolved, locked as path: and built from their directory. Since 2026-10-12 an archive: source is resolved too: the archive is fetched (a file: URL relative to the root project, any other one with curl or wget), unpacked with the safety checks of a registry archive, refused unless its tree hash is the one hash: names, and installed into the cache under that hash, where --offline and a second install take it from; its own project.trb must name the package of the source line, and its dependencies join the resolution like those of a path: package. Since 2026-10-12 a git: source is resolved as well: the git executable on the machine (TORB_GIT names another one) fetches the revision: into a scratch directory under the cache - shallow, and every branch and tag where the server fetches no commit by a short id - the commit is checked out, and the package's files (the ones an archive of it would hold; a symbolic link anywhere is refused) are checked like an archive's and installed under their tree hash. The lock pins the commit and the tree hash, a locked commit is fetched again rather than what its tag or branch names now, a warm cache or --offline runs no git, and its project.trb must name the package. A file: registry URL is relative to the root project of the workspace. Since 2026-10-08 (docs/design/URI.md slice 9) a git: or archive: location and an https: or http: registry URL are read as a Uri, and one that is not a URI is a problem at its line of the manifest, before anything is fetched; a file: registry keeps the directory as written, and path: stays a directory.

8. What a project file may read#

A project.trb may read files below its own directory and the environment it was started in. It may do nothing else. No network, no writing, no clock, no processes, no foreign functions.

use File from "std/fs"
use Environment from "std/os/environment"

name = "acme/shop"
version = Environment.get("CI_COMMIT_TAG") ?? File.readText("VERSION").ok()?.trim() ?? "0.0.0"

The toolchain is the caller of this receiver script, and a caller grants capabilities at the call site — which is CONCEPT's rule for the sandbox and not a new one. Written out, the grant is — with the member name docs/design/RESOURCES.md section 5 gives it, because the path of a project.trb is a directory somebody typed on a command line and is therefore not known when the toolchain is compiled:

const manifest = Sandbox.read<Project>(projectDirectory.joined("project.trb")) {
  modules "std/fs", "std/text", "std/os/environment"
  files readOnly: <the project directory>
  environment "*"
  limits steps: 1_000_000, memory: 16.megabytes(), time: 2.seconds()
}?
  • Three modules and no more. std/fs is the file reading; std/text is what you do with the text you read (trim, lines, split); std/os/environment is Environment - get(name): String?, the listing and the search path - and nothing else of std/os. std/time would be a clock, std/process a shell, std/http a network: each of those is a way for a manifest to reach something the project neither contains nor was handed, which is the line this grant draws.
  • files readOnly: is the project directory, and docs/design/PATH.md section 7 is what "below" means. The check is lexical — the text of the path does not leave the root — and the sandbox's own natives additionally refuse a component that is a symbolic link at open time, because section 7 names the sandbox as the one caller that must not accept the lexical gap. So File.readText("../../etc/passwd") is a SandboxError naming the path and the root, and so is a VERSION that is a link out of the tree. What is not promised is anything about the file's content: a manifest may read a file the project owns, and the project owns whatever is in its directory.
  • environment "*" — every variable, and the narrower pattern is rejected. A pattern has to be written somewhere, and there are only two places, both wrong. In the manifest itself it would be the script granting itself a capability, which CONCEPT forbids in so many words ("a script that could grant itself capabilities would not be a sandbox"). Fixed in the toolchain as, say, TORB_*, it would exclude every variable anybody actually has: CI_COMMIT_TAG, GITHUB_REF_NAME, BUILD_NUMBER share no prefix and never will. And the sandbox here is not protecting the user from the manifest — the user wrote it and is about to compile the code it names. What the sandbox protects is that the toolchain knows what the manifest did, and that is served by recording the names read, not by narrowing the set.
  • The limits are the toolchain's, and they are why loop { } in a manifest is a diagnostic and not a hang.

Reading the environment, and its two pitfalls#

Reading a variable is what a CI job actually wants: a version that comes from a tag, without a commit that changes VERSION before every release. It is granted, and the two things that used to be arguments against it are true anyway, so they are written down as pitfalls rather than as a prohibition. Both belong in docs/tooling/project-trb.md under # Pitfalls.

  1. Whatever flows from the environment into a setting is published with the package. version ends up in the locked manifest below, which is the file a registry, a consumer and an editor read. A manifest that writes description = Environment.get("NPM_TOKEN") ?? "" publishes the token, and no mechanism can tell a token from a tag. What the design can do is make it visible at the moment it matters: the locked manifest records which variables were read (by name), and torb publish prints that list before it uploads. It asks for no flag and no confirmation: reading a variable is the most ordinary thing a configuration file written in TorbScript does - more ordinary than reading a file - and a manifest that does it is not doing anything that needs permission.
  2. A build is reproducible only together with the variables it read. Two checkouts of one commit with two environments are two packages. That is not a defect of the grant, it is what the grant is for — but it means "the commit" stops being the whole input, and a bug report that says "it builds here and not there" has one more place to look. The locked manifest and build/manifest-inputs.trb both name the variables, so the place to look is written down; nothing makes it unnecessary.

A dependency does not get the environment. The grant above is for the project torb was invoked in and for its workspace members, and for nothing else. A git: or path: dependency is evaluated with file access to its own directory and without std/os/environment in modules — so a dependency's manifest that reads a variable fails to load, with the sandbox's own "a module the script may not import" naming the module and the dependency. Refusing the module rather than handing back None is deliberate: Environment.get answers None both for a variable that is unset and for one the sandbox denied (Environment.get says so under # Pitfalls), so a silent denial would let a dependency fall back to some default and nothing would ever say why. The reason for the refusal is the first pitfall read backwards: a dependency that could read your environment could carry your secret into its own settings, and from there into your binary.

The locked manifest: installing still never runs code#

There are exactly two files. project.trb is the one a person maintains. project.lock.trb is the locked manifest: the evaluated, purely literal settings and the pinned dependency graph, in one file, in the same vocabulary printed back. There is no third file, no frozen copy under another name, and no frozen file that is also called project.trb.

A consumer, a registry and an editor read the lock of a foreign package and never evaluate its project.trb. That is CONCEPT's "installing never runs code", kept exactly, and it is now one sentence instead of a rule about which of two project.trb files you are holding.

// project.lock.trb - written by `torb add`, `torb update`, `torb lock` and `torb publish`. Do not edit.

language = "0.3.0"

settings "acme/shop" {
  version = "1.4.2"
  description = "A shop"
  license = "MIT"
  authors "Ada Lovelace"
  prelude = "std/prelude"
  program "shop", entry: "src/main.trb"
  program "migrate", entry: "tools/migrate.trb"
  dependencies {
    runtime "acme/http:^1.2.3"
  }
  resource "acme/shop/src/templates/invoice.html"
  from {
    file "VERSION", hash: "sha256:..."
    variable "CI_COMMIT_TAG", hash: "sha256:..."
  }
}

graph {
  package "acme/http", version: "1.2.5", registry: "acme", hash: "sha256:..."
  package "acme/json", version: "2.0.1", registry: "acme", hash: "sha256:..."
  package "acme/local", path: "../local"
}
  • settings is the evaluated Project, printed back as the command calls that would produce it, in a fixed order, every argument a plain string or number literal. This is docs/design/ENCODING.md's principle one level up: a value is its constructor call, and a receiver script is its settings. There is no second format, no .toml, no JSON.
  • settings names its package, so a workspace root's lock has one block per member. That is what CONCEPT's "one project.lock.trb, at the root" already asks for: one resolution for everybody (graph, once) and one set of settings per package (settings, per member). A published archive carries a lock with exactly one settings block and no graph.
  • settings carries what a consumer needs and nothing else: language, version, prelude, dependencies, the program lines, the metadata, and the resource list (docs/design/RESOURCES.md, so a consumer sees every non-code file a dependency carries without opening the archive). It carries no source and no registry — where a package comes from is the building project's decision, and a dependency that could republish its own source lines would be dependency confusion by another route — and no workspace, which is about a tree the consumer does not have, and no profile or test, which are about a build the consumer is not running.
  • from is the provenance of the evaluation: which files and which variables the script read, each by name and by a hash of what it answered. It is what makes pitfall 1 visible and pitfall 2 diagnosable, and it is the published half of build/manifest-inputs.trb, which has the same two line shapes.
  • graph is what CONCEPT's lock file already pins (section 11), unchanged.
  • A published package carries both files, whole. project.trb travels so that a person can read how the version was computed and what the author actually wrote; project.lock.trb travels because it is the only thing the toolchain reads. npm and Bun ship the pair for the same reason, and it is what makes the third file unnecessary. The graph section travels with it and is information, not resolution: it records what the package was built and tested against, which is the first thing a bug report wants, and a consumer resolves its own graph from its own root lock and never from a dependency's.
  • torb publish verifies the settings section with the static reader and refuses if a setting did not survive being printed back — which makes "a published manifest is statically readable" a checked property rather than a hope.
  • A package without a project.lock.trb is not installable. There is nothing a consumer may read, and evaluating a foreign project.trb is the one thing this design does not do. torb publish writes the file, so the case only arises for an archive somebody assembled by hand, and the message says which file is missing.

Writing the lock is deterministic: the same inputs produce a byte-identical file. That is a requirement and not an aspiration, because the file is checked into a repository, reviewed in a diff and compared by a registry, and a file that differs between two machines is a file everybody learns to ignore.

  • A fixed order, everywhere. The sections in the order language, settings, graph; one settings block per package, sorted by package name; the settings inside a block in the vocabulary's own declaration order (section 10's table, top to bottom), never in the order the manifest happened to write them; from entries by kind and then by name; graph packages by name; program lines in the order the manifest declares them, which is the one order that is itself meaningful (section 4: torb build builds them in it).
  • Nothing about the machine, ever. No timestamp, no tool build identifier, no absolute path, no user name, no locale-dependent formatting. A path: source is written relative to the lock file. This is the same rule the C back end already lives under — "no path of this machine, no time and no build id is in it, so two runs of the compiler write the same bytes", which the generated C says in its own header.
  • One spelling per value. Hashes as sha256: plus lowercase hex; numbers in the canonical form torb canon already defines for a literal; strings with canon's escapes; UTF-8, LF, no BOM, exactly one trailing newline.
  • How it is checked. torb lock --check writes the file into memory and compares it byte for byte with the one on disk, failing with a diff — which is exactly the shape of canon --check and docs index --check, two gates this repository already runs on every change, so it costs a gate and no new machinery. Two further checks fall out of it for free: writing the lock twice must produce the same bytes (idempotence, asserted in the toolchain's own tests), and once the VM exists, stage 0 and the compiled torb must write the same bytes for one project — which is the conformance suite's contract applied to a file instead of to standard output. Built 2026-09-27: torb lock --check fails with error: project.lock.trb is not what `torb lock` writes, the first ten lines that differ as line N: on disk `...`, written `...` and the command that writes it; tools/gates.sh a runs it over every project.lock.trb git knows.

Which manifest is evaluated, and which is read from a lock#

A package you build from source is evaluated; a package you install is read from its lock. That is the whole rule.

The packageWhat the toolchain doesThe environment
the project itselfits project.trb is evaluated, scoped to its own directoryyes
a workspace memberits project.trb is evaluated, scoped to the member's directoryyes, the same one
a path: sourcethe same, scoped to its directoryno
a git: sourcethe same, scoped to the checkout — it is source you chose to build fromno
a registry packagenever evaluated. The settings section of its project.lock.trb is all the toolchain reads; its project.trb ships for a human and the toolchain does not parse it—

The alternative for members and path:/git: sources — require them to be literal-only — was considered and rejected: it creates two dialects of one file inside one repository, which is the disease section 1 is a list of, and it buys nothing, because the grant is file access to a directory whose source you are about to compile anyway. The environment is the one capability where the line is worth drawing, and the table draws it.

A member's grant is its own directory, not the root's. A member that wants the workspace's version inherits it (section 11), which already works and needs no read; a member that reads ../../VERSION gets a SandboxError. That keeps the rule one sentence and makes the common case free.

Your own lock's settings section is never read by your own build. It is written for other people. What is in your tree is project.trb, and a build evaluates it — so an absent or stale settings section in your own lock is not an error, not a warning and not something a build fixes on the way past. The reason is pitfall 2 turned into a rule: if every build rewrote the section, a BUILD_NUMBER would change a checked-in file on every CI run, and the one file that is supposed to be stable would be the noisiest thing in the repository. So:

  • settings is written by torb publish, by torb pack, and by an explicit torb lock. Nothing else writes it.
  • graph is written by torb add, torb remove and torb update, exactly as CONCEPT already says.
  • torb run, build, test and check read graph, never write it, and refuse when it does not match project.trb — also exactly as CONCEPT already says — and they ignore settings completely.

Errors, determinism and caching#

  • A failure of the script is a diagnostic against project.trb, with the line the SandboxError carries, never a toolchain crash — which is the reason for a sandbox rather than an include. A capability refusal names the path and the granted root; a panic inside the script is reported as one; a step, memory or time limit is reported as itself.

  • The static read comes first. It is what finds the workspace, the dependencies and the entry files, and it cannot depend on evaluation. A manifest whose static settings are not plain literals (section 10) is reported by the static reader and never evaluated.

  • A manifest that says nothing computed is never evaluated at all. If the static read covers every setting the file writes — which is true of every manifest in this repository today — the toolchain has the whole Project already and starting a VM would be work with no result. So the cost of section 8 is paid only by the projects that use it.

  • The inputs of an evaluation are recorded, by name and by hash, never by value.

    // build/manifest-inputs.trb - written by the build. Do not edit.
    file "project.trb", hash: "4215339106279925103"
    file "VERSION", hash: "n1867339253446187461"
    variable "CI_COMMIT_TAG", hash: "7719027363150917264"
    variable "BUILD_NUMBER", unset: true
    settings "version = \"1.4.0\"\n"

    An incremental build re-evaluates the manifest when one of the hashes no longer matches and not otherwise. That is sound precisely because the grant has no clock, no network and no writing: given the same files and the same variables, the evaluation has the same result. The last line is what the evaluation answered, the settings the settings section of a lock publishes anyway, which the next build reads instead of evaluating again. The hash is the toolchain's own 64-bit hash of the text - of a directory, of its names; of a file that is not there, missing - which says whether an input changed and is no digest of it: a SHA-256 waits for a hashing module of the standard library.

    A variable is never recorded by value. A manifest may legitimately read something that is a secret in a build that is not publishing, and a build artifact that held the value would be a place a secret ends up without anybody deciding that it should. The hash is taken over the name and the value together, so two variables that happen to hold one string do not look alike. The honest limit: a hash of a low-entropy value — a flag, a four-digit number — is guessable, so this file is a local build artifact under build/, which .gitignore already covers and which nothing publishes, and it is never an input to anything that leaves the machine.

    A variable that was not set is recorded too. Environment.get "BUILD_NUMBER" answering None is a read whose answer changes the moment somebody sets it, so unset: true is an input like any other. Without it, exporting a variable would not invalidate the cache and the first build after it would be wrong.

    The lock file's from { } section (above) has the same two line shapes for the same two kinds of input. The difference is lifetime and audience: build/manifest-inputs.trb is rewritten by every build and read by the next one, and from { } is written when the package is locked and read by whoever looks at the package.

The toolchain reads the evaluation#

Since milestone 7.5 the VM evaluates a project.trb under the grant above (docs/design/SCRIPTS.md section 7): torb manifest prints what the evaluation configured, and torb manifest --check, a gate of tier A, asserts that the static reader reads the same from it as from the file on every manifest of the repository. Since SCRIPTS.md's slice 5 the toolchain reads it, too, where the static read is not enough:

  • A static setting that is computed is refused, at its value, with the file, the line and the column and the note of section 10 (manifestProblemsOf of compiler/src/project/manifest.trb, a problem of the workspace): the nine static rows, program among them, whose entry names an entry file. It is never evaluated.
  • A setting the toolchain uses that is computed is evaluated: version, tasks { ... }, a profile block, or a line that is no setting at all - an if, a loop - which may configure anything (needsEvaluation). The build, torb run and torb test evaluate that manifest and read version, tasks and the profiles from the settings it answers (manifestOfProject of compiler/src/vm/manifest.trb); everything that decides the workspace still comes from the static read. A stop of the script is an error of the build, with the line of project.trb.
  • What is computed and read by nobody - description, license, a const - is not evaluated at all. So the repository's own manifests, which are literal, cost nothing, and neither does a manifest that computes only its description.
  • build/manifest-inputs.trb keeps what the evaluation read, as the sandbox recorded it (the grant's record), and what it answered, as described above.

9. Resources, as far as the project file is concerned#

docs/design/RESOURCES.md is the design. What belongs here is only what the project file, the build output and the specifier grammar have to say about it.

  • A resource is named by a string literal at the call site and resolved by the compiler, against the directory of the file that writes it. There is no list of assets in the manifest: SwiftPM's resources: states the honest requirement — the build must know every non-code file a program needs — and the call sites already answer it.

  • A resource path is not a module specifier (section 6, the last row of the table): it carries its extension, it is file-relative where it is written, and what is recorded is the project-relative name.

  • The build output layout. A program's shipped resources go beside the binary, under a directory named after the program, each at its stable name — <owner>/<name>/<path inside the package>, which is the text the compiler already builds for a panic site. The run time finds them relative to the program and never to the working directory:

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

    A program line's output moves both: output: "dist/migrate" is dist/migrate and dist/migrate.resources/.

  • The published package's file list. The settings section of project.lock.trb (section 8) carries every resource path the package's own code names, as resource "..." lines, so a consumer can see the non-code files of a dependency without evaluating anything and torb publish can refuse a package whose code names a file the archive does not contain.

  • The one thing project.trb says about resources is a budget for what gets embedded.

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

    What is measured is the embedded total per program — the sum of every EmbeddedBytes and EmbeddedText that ends up in that program's binary. Above the first number the build report says so; above the second the build fails and names the three largest contributors. A single file gets no limit of its own, deliberately: one oversized file blows the total as well, so a per-file limit catches nothing extra, while the case it would be needed for — a hundred files of two hundred kilobytes each — is exactly the case a per-file limit does not catch. One number for the thing that actually costs build time is better than two numbers that disagree about which one fired.

    The defaults are 4.megabytes() and 64.megabytes(), argued from measurement (docs/design/RESOURCES.md probe 3: 1 MB embeds in a 21 s build, 16 MB in a 196 s build, so a megabyte of payload is about twelve seconds of build time). Four megabytes is roughly a minute of build — the point at which somebody notices a rebuild and deserves to be told why. Sixty-four is roughly thirteen minutes and about four hundred megabytes of generated C, which is past "slow" and into "the CI job is broken and nobody knows it"; that is the accident — an assets/ directory embedded by a loop somebody wrote without thinking — and an accident is worth a stop. Raising either is one line.

    The "warning" is a line on the build report, not a new diagnostic severity. The toolchain has errors and notes and nothing in between today, and one budget is not a reason to introduce a third severity with the flags that come with it. torb build already reports what it wrote; this is one more line of that report. If a warning severity ever arrives for a reason of its own, this becomes one and the setting does not change.

    Neither setting is static (section 10): nothing before evaluation needs them, no editor, registry or torb add reads them, and they are about your build rather than about the package, so they are not in the lock's settings either — the same place profile and test sit.

    megabytes() moved. kilobytes, megabytes and gigabytes on Int64 lived in std/sandbox, where SandboxCapabilities.limits needs them. They are in std/number now and answer a ByteSize (OS slice 2), so that a project.trb can write 4.megabytes() with no import and there is exactly one definition for both callers. The two resources settings are ByteSizes for that reason.

  • Nothing else in project.trb configures a resource, and a budget is not the exception it looks like. No assets: list, no loader table, no per-file setting. The objection this design makes to SwiftPM's resources: is that it is a second source of truth about which files exist, which can disagree with the call sites; a budget says nothing about which files exist and cannot disagree with anything — it is a limit on a number the build computes from the call sites themselves. A manifest that listed assets would be a second source of truth about the same files, which is section 1's disease, and a manifest that configured loaders would be a build script, which is CONCEPT's non-goal.

10. The vocabulary#

Every setting of project.trb. Static means the toolchain reads it from the syntax tree without evaluating the file, which is what stage 0 does today, what an editor wants before anything is checked, what a registry wants without running anything, and what the checker needs before it checks a file.

SettingTypeDefaultStaticError
language = "0.3.0"Stringnoneyesan older toolchain refuses the project, and says so before reading anything else
name = "owner/name"Stringnoneyesmissing, not owner/name, or not a plain string
prelude = "std/prelude"String"std/prelude"yesnot a package specifier; the package must exist
dependencies { runtime "..." }variadicnoneyesnot owner/name[:requirement]
dependencies { development "..." }variadicnoneyesthe same
source "owner/name", git:/path:/archive:methodthe owner's registryyestwo source lines for one package; git: without revision:; archive: without hash:
registry "owner", url: "..."methodnoneyestwo registries for one owner
workspace { members "..." }variadicnoneyesa pattern that matches no project; a member without a project.trb
program "name", entry:, output:methodsrc/main.trb is the one programyessection 4's table
version = "1.4.0"Stringthe workspace root'snonot a version; required to publish
authors "..."variadicthe workspace root'sno—
keywords "..."variadicnonenoat publish: more than 5, one not 2 to 24 characters of a-z, 0-9, - starting with a letter or digit, one twice
description = "..."String""norequired to publish
license = "MIT"String""norequired to publish
repository = "https://..."Stringthe workspace root'sno—
profile "release" { ... }methodthe built-in profilesnoan unknown profile name
test { coverageThreshold = 80 }Int0nooutside 0..100
resources { embeddedWarningAbove }ByteSize4.megabytes()nonegative, or above embeddedErrorAbove
resources { embeddedErrorAbove }ByteSize64.megabytes()nonegative

The first nine are static, and that is a promise with teeth: they are top-level statements - a field assignment or a command call - whose arguments are plain string literals. Everything below them may be computed, because nothing is read before the file can be evaluated.

version moved. The previous round listed it among the static settings. Section 8's whole point is that version = Environment.get("CI_COMMIT_TAG") ?? File.readText("VERSION").ok()?.trim() is the motivating example, so version is exactly the setting that must be allowed to compute. What a registry and a consumer need is the version of a published package, and that comes from the settings section of its project.lock.trb, where it is a literal again. A tool that wants the version of a project it is not building reads that lock; a tool that wants the version of a project it is building evaluates the manifest. There is no third answer and there does not need to be.

language, the minimum-version setting#

language = "0.3.0"

The setting versions one thing, which is why it is one number, and that thing is the language. The standard library is part of the language here, not a layer beside it: std ships with every toolchain, it is never chosen or pinned separately, and everything a program does not use is erased from the binary anyway, so there is no cost that would make a separable std worth having. That is the difference from Rust, where std is a real layer — no_std is a thing you can be, a target can lack it, and a crate can be written against a language edition without it. Nothing here is like that. The language version and the std version always coincide, so the number is the language's and so is its name.

A torb binary is a front end, a back end, runtime/*.c and std/* built from one commit. There is nothing to version separately — no "language edition" next to a "tool version", no SDK next to a compiler — and the setting says one thing: this project needs the language at least this new. It is the one setting an older toolchain has to understand, which is why it is read first, before anything else in the file, and why adding it late would mean the versions that cannot read it already exist.

The message an older toolchain prints is the one thing to get right, because it is the only thing that toolchain will ever say about the project:

error: This project needs language 0.3.0, this is 0.2.1
 --> project.trb:1:12
  |
1 | language = "0.3.0"
  |            ^^^^^^^
  = Nothing else in this file was read. Install a newer toolchain

Both numbers, in that order, and the note says that nothing else was read — so nobody goes looking for a second problem that the toolchain never got far enough to find.

EcosystemWhat it is calledWhat it versions
Rustrust-version = "1.75" in [package]the toolchain: compiler plus std
Gogo 1.21 in go.modthe language version and the toolchain, in one line
Node"engines": { "node": ">=18" }the runtime only; the compiler is somebody else's
Dartenvironment: sdk: '>=3.0.0' in pubspec.yamlthe SDK: compiler, runtime and core libraries
Swift// swift-tools-version:5.9, a comment on the first line of Package.swiftthe manifest format itself

Dart is the closest match, because its SDK is the same bundle ours is. Swift is the instructive one: the version lives in a comment, on the first line, because Package.swift is a program and the version has to be readable before the program can be parsed. A project.trb needs no such hack — the setting is a static field assignment (section 8), read from the syntax tree before anything is evaluated, so it can be an ordinary line like every other setting.

What was considered, with language first:

CandidateForAgainst
language = "0.3.0" — decidednames what is actually versioned, once std is understood as part of the language; a full word that needs no gloss; nothing in the sentence "this project needs language 0.3.0" has to be looked upit is not the name of the thing you install, so a user who has to act on the message installs "torb" — which the note in the diagnostic says
torb = "0.3.0"the tool's own name is the thing you installedit versions the tool rather than what the tool implements, and a reader might briefly expect it to configure torb, with program "torb" three lines away in one repository
toolchain = "0.3.0"what Rust calls it and what this document still calls the program in prose"toolchain" is a word a user has to learn maps to "the thing you installed", and it names the packaging rather than the contract
torbscript = "0.3.0"the language's name, spelled outthe language's name in a file of that language is noise, and it reads like a dependency on a package called torbscript
minimumVersion = "0.3.0"precise about the comparisonsilent about the minimum version of what, which is the only interesting part
requires = "0.3.0"shortreads like a dependency, and dependencies are two lines below

The prose in this document and in the toolchain's own messages still says toolchain for the program that reads the file, and that is not a contradiction: the toolchain is what you install, the language is what it implements, and the setting is a statement about the second.

Which file the static reader is pointed at depends on whose package it is, and that is the whole of section 8 in one table:

What is being readThe fileHow
the project torb was invoked inits project.trbstatically; then evaluated if a setting is not a literal
a workspace memberits project.trbthe same
a path: or git: dependencyits project.trbthe same, without the environment
a registry packageits project.lock.trb, settings sectionstatically, always — its project.trb is never parsed by the toolchain
the dependency graph of the buildthe root's project.lock.trb, graph sectionstatically
an editor or a registry looking at a foreign packageits project.lock.trb, settings sectionstatically

So a project file may do this —

use File from "std/fs"
use Environment from "std/os/environment"

name = "acme/shop"
version = Environment.get("CI_COMMIT_TAG") ?? File.readText("VERSION").ok()?.trim() ?? "0.0.0"

const threshold = if version.startsWith("0.") { 50 } else { 80 }

test {
  coverageThreshold = threshold
}

— and may not do this:

const suffix = "shop"

name = "acme/{suffix}"

The static reader reads no name from that second file, and the workspace reports it as an error (manifestProblemsOf):

error: `name` has to be a plain string
 --> project.trb:3:8
  = The toolchain reads `name` before it can run anything, so it cannot be computed

That one rule is what makes torb add and torb update possible: they edit a dependencies { } block whose lines are plain calls, and a block that is computed is one they refuse to touch with a message instead of rewriting.

What does not belong in a project file.

  • Sandbox capabilities. Granted at the call site that loads the script, never here. CONCEPT's decision, unchanged, and section 8 is the same rule applied to the toolchain itself: the toolchain is a caller, so it grants; the manifest is a script, so it receives and cannot widen.
  • native and foreign. They are keywords in source, so they are visible where they are used, and torb add reads them from the dependency's code. A manifest switch would be a second source of truth and the first thing to go stale.
  • Build scripts, install scripts, code generators, loaders, asset lists. Sections 8 and 9, and docs/design/RESOURCES.md.
  • input, output as a whole-project setting, target. Sections 3, 4 and 5.
  • An exports list. public and "owner/name/path" already are it.
  • Feature flags and optional dependencies. CONCEPT already decided: an optional integration is a package.
  • Anything per file except a program's entry. A file's name is what the project says about it; entry is the one exception and section 4 is the argument for it.

test { coverageThreshold }, line coverage in the VM#

Decided in issue #191, option A. The setting is the share of lines a torb test run has to reach:

  • The unit is a line. A line counts when a statement begins on it: a statement of a body (of a function, a method, a closure, a block inside one) that runs something, and the body of a match arm that is not a block. A declaration inside a body is no line of its own. These are exactly the places the lowering for the debugger puts a statement instruction (docs/design/DEBUGGER.md section 5), so a line is something the debugger can stop on.

  • The lines are read off the syntax tree, the reached ones off the run. The lowering only reaches the functions the program calls, and a function no test calls is what a coverage has to count as missed; so the lines of a file come from its parse (statementLinesOf, compiler/src/vm/coverage-report.trb) and the reached ones from the locations of the statement instructions the loop executed (Coverage, compiler/src/vm/coverage.trb).

  • Scope. The src/ files of the package whose tests run. The top-level code of a file and everything inside it is not counted: it is the program of an entry file, which a test never runs. Tests and dependencies are not counted.

  • Measured in the VM, and only when the threshold is above 0. Such a run is lowered for the debugger, which costs the 12–35% of DEBUGGER.md section 19 and keeps every task on one thread, as a debugged run does. A threshold of 0, the default, changes nothing. torb test --native with a threshold above 0 is an error: a binary has no statement instructions to count.

  • Below the threshold the run says so in one line after the summary and exits non-zero:

    coverage 71% is below the threshold 80%

    The share is rounded down and compared exactly, so 79.9% never passes as 80%. A failing test's exit code wins over the coverage's. --report json carries the same as one more event, {"event":"coverage","percent":71,"threshold":80,"lines":120,"reached":86}. A table per file comes later.

  • Known limits. The lines of a compile-time branch for another target count as missed, and so does a statement after one that always diverges, which the lowering never reaches either.

The measurement and the check are in. The manifest reads test { coverageThreshold }, statically where it is a literal, and a share outside 0..100 is a problem of the workspace. torb test measures every package whose tests run over its own src/, so a file no test imports counts as missed, and checks each against its own threshold: one run of the VM, one line or event per package, and in a run of several packages the line starts with the package's name (acme/app: coverage 71% ...) and the event carries "package". --filter and --shard run a part of the tests, so with either nothing is measured and one line on standard error says so; --native, --profile and --release are refused before the build with error: `test { coverageThreshold = 80 }` of acme/app is measured in the VM: run `torb test` without `--native` . compiler/tests/coverage.test.trb pins the measurement and the grouping by package, and tools/test-report.sh the report over the package of tests/test-report/coverage/. examples/game-engine/project.trb sets no threshold any more, because the gates run its tests with --native. The user documentation is the next slice.

11. Workspaces and the lock file#

A member inherits version, authors, license, repository, the registries, the source lines and the profile blocks of the root, unless it sets its own. It never inherits name, dependencies, prelude or program: the first is what it is, the next two are what the rule of section 1 exists to keep honest — a member still has to name a sibling as a dependency to import it (probe 9), and a workspace is not a way to skip that — and the last is about files that are inside the member.

Inheritance happens after evaluation, not before it. A member whose version is computed keeps its own; a member that says nothing about version takes the root's evaluated value. That is what makes section 8's "a member's grant is its own directory" cheap: the root already read the VERSION file, and the member gets the answer rather than the file.

source lines are inherited and never overridden by a member. Where a package comes from is a property of the build, not of whoever happens to import it; two members resolving one name to two places is the thing the lock file exists to make impossible.

One project.lock.trb, at the root, for every member — and it is the workspace's own lock, with one graph section and one settings section per member. Section 8 has the shape of the file; this is what the graph section pins, per package:

SourcePinned
a registrythe exact version, the content hash, and which registry answered
git:the resolved commit, and the content hash of the tree at it
archive:the URL and the content hash
path:the path, and nothing else — it is source the project owns, and a hash of it would change on every edit

A path: source is listed rather than pinned so that a reviewer sees that one dependency is not reproducible; that is the honest half of what a lock file can promise about a directory somebody is editing.

The lock pins the version a package was resolved at, which for a source-built package is the version its evaluated manifest answered. That is the one place section 8 reaches the lock file: a git: dependency whose manifest computes its version from a file in its own tree is pinned by commit and content hash, so the computed version is reproducible for the same reason the tree is. A registry package's version is a literal in the settings section of its own lock and needs nothing.

torb run, build, test and check read the lock and never write it, and refuse when it does not match project.trb. torb add, torb remove and torb update write it. Both halves are already CONCEPT's position; what this document adds is that the same static subset (section 10) is what makes the writing half possible on a file that is code.

The exact fields are decided (2026-09-26), with the registry protocol of docs/design/RELEASE.md section 7.4, and are what docs/tooling/project-lock-trb.md shows: package "<name>", version:, registry:, hash: for a registry package, with an optional capabilities block, and package "<name>", path: for a path: one. registry: is the URL the owner is bound to, as the canonical text of its Uri (https://packages.torb.dev/ for an unbound owner; a file: registry as written), not the owner's name the sketch of section 8 used: a binding that changes after locking is then a mismatch the build reports, which is the point of pinning the registry. A lock written before 2026-10-08 has the URL without the /; it is read as the same registry, and torb lock rewrites it once. Since 2026-10-12 an archive: source is locked as package "<name>", archive:, hash: - its URL as the source line has it (a file: one as written, any other one as the canonical text of its Uri) and its tree hash - a row an older lock never has, so the lock language did not change. Since the same day a git: source is locked as package "<name>", git:, ref:, revision:, hash: - its URL as the source line has it, the line's revision: as ref: where that is a tag or a branch and not the full commit id, the full commit id as revision:, and the tree hash of its files. The top-level language is the root project's language, and a settings block - written by torb publish for the package it publishes and, since 2026-09-27, by torb lock for every member, each from its evaluated manifest - carries language, prelude, dependencies, version, authors, keywords, description, license and repository: no program lines and no resource list yet, because nothing reads either from a lock, and no from section, because the sandbox does not record what an evaluation read yet. torb lock keeps the graph as it is - only add, remove and update change it - and refuses a graph that does not match project.trb with the problems that say run torb update.

12. Migration#

Nine 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 6 need nothing that does not exist; slices 7 and 8 need the VM; slice 9 is docs/design/RESOURCES.md's own plan.

All of this lands after the repository has moved to static and var fn, because that round touches practically every .trb file and nothing should be rebased across it.

#SliceFilesRisk
1The names decide. isEntryFile stops reading buildInput and reads the file name; isLibraryModule and it stop disagreeing; packageAt takes the whole package directory instead of src/ plus testInput; torb build picks its entry from the programs; torb test collects *.test.trb below the package. Manifest loses buildInput and testInputcompiler/src/project/{manifest,workspace}.trb, compiler/src/semantics/graph.trb, compiler/src/semantics/checker/declaration.trb, compiler/src/cli/{build,test}.trb, compiler/tests/project.test.trbMedium. Probe 18 says the thirty-two build { input = "src/lib.trb" } blocks are currently switching a rule off; removing them turns that rule back on, so every src/lib.trb in the repository is checked for top-level code for the first time
2The manifests. The build { } and test { input } blocks go out of all thirty-five project.trb files; Build is deleted from std/project and Test keeps only coverageThresholdstd/project/src/lib.trb, every project.trb, docs/standard-library/project.mdLow, and it is the slice that proves slice 1, because nothing may change behaviour
3Programs. Program and Project.program in std/project; the static reader reads program lines; torb run <name>, torb build <name>, the "name one" diagnostic, the library-only "nothing to build"; the ten diagnostics of section 4; "owner/name/main" and an imported entry become errors; compiler/project.trb writes program "torb"std/project/src/lib.trb, compiler/src/project/manifest.trb, compiler/src/cli/{build,run,test}.trb, compiler/src/semantics/graph.trb, compiler/project.trbLow. No file moves and no import changes. The one thing to watch is the hardcoded src/main.trb for a directory argument in the driver, which has to learn the program lines so that torb run <dir> of a renamed default still finds it
4Profiles and targets. --profile, --release, --target, with dev as the default; build/<profile>/<program>; the profile block in the vocabulary and in the static reader; output on a program taken literallycompiler/src/cli/build.trb, std/project/src/lib.trb, compiler/src/project/manifest.trbLow. buildTarget = "release" is one constant today, and the layout already has the shape
5The specifier grammar. One function that takes a specifier apart, with a message per shape: a dot in a relative component, a scheme:, a host-qualified owner, a .. inside a package path, a climb out of the packagecompiler/src/semantics/graph.trb, compiler/src/semantics/scope.trb, compiler/tests/check.test.trbLow, and it is the slice with the most new diagnostics, so it is mostly tests with exact messages
6Sources and the static subset. source in std/project and in the static reader; a plain-string rule with a diagnostic for the nine static settings; language, description, license, repositorystd/project/src/lib.trb, compiler/src/project/manifest.trb, compiler/tests/project.test.trbLow on its own. It does not resolve anything — resolution needs the registry protocol, which is CONCEPT's open question
7The manifest that reads. The toolchain becomes a Sandbox caller: the grant of section 8 (files, the environment for the invoked project and its members only, three modules), the evaluation only when the static read is not enough, build/manifest-inputs.trb by name and hash, the diagnostics for a failing scriptcompiler/src/project/*, compiler/src/cli/*, std/sandbox, std/os, the VMDone as SCRIPTS.md's slice 5 (section 8, "The toolchain reads the evaluation"): every manifest the toolchain evaluates gets the environment, because no dependency is resolved yet, and the hash is the toolchain's own until a hashing module exists
8The locked manifest. Lock in std/project with its settings and graph sections; the deterministic printer that writes an evaluated Project back as literals in a fixed order; torb lock and torb lock --check; torb publish writing and verifying settings and printing from; both files travelling in an archive; the consumer side reading a dependency's settings instead of its project.trbstd/project/src/lib.trb, compiler/src/project/*, compiler/src/cli/*Medium, and it needs slice 7 in front of it. The printer is the interesting half: "a value is its constructor call" has to hold for the whole vocabulary, torb lock --check is the gate that says it is deterministic, and torb publish's static re-read is the one that says it round-trips
9Resources. docs/design/RESOURCES.md's slices, which are a plan of their ownsee that documentsee that document

Where the slices stand (2026-09-27). Slices 1 to 6 and 8 are done; slice 7 was done before, as SCRIPTS.md's slice 5.

  • Slice 1: the names of the files decide (section 3, "As built"). Manifest has no buildInput and no testInput, a package is its whole directory, src/lib.trb is never an entry, and torb test without a path tests the package it is run in. Removing the thirty-two build { input = "src/lib.trb" } blocks found no top-level code in any library.
  • Slice 2: no project.trb of the repository writes build { } or test { input }. Build is gone from std/project and Test keeps coverageThreshold alone, which nothing reads yet; a manifest that still writes build { } does not check, because Project has no build.
  • Slice 3: Program and Project.program, the static reader's program lines, torb run [name], torb build [name], "Name one", "nothing to build" and every rule of section 4's table, at the manifest or at the import (section 4, "Programs as built"). compiler/project.trb says program "torb".
  • Slice 4: the profile block, Profile and Project.profile, -O<optimize> and -g, inheritance from the workspace root, build/<profile>/<program> and build/<target>/<profile>/<program>, and a literal output (section 5, "As built"). torb build kept release as its default, which section 5 decides and argues.
  • Slice 5: specifierOf in compiler/src/package/specifier.trb takes a specifier apart with a message per shape (a URL, a host-qualified owner, a dot in a component), and the module graph now refuses a relative path that leaves its package or names a file of a nested one (section 6).
  • Slice 6: source, registry, language, description, license and repository are in std/project and in the static reader, and the plain-string rule of the nine static settings (manifestProblemsOf) has tests for source, registry, language and program besides the others.
  • Slice 8: Lock with its settings and graph, the deterministic printer, torb publish writing the settings block from the evaluation and carrying both files in the archive, the consumer side reading a dependency's settings instead of its project.trb, and torb lock [--check] (section 8), which is a gate of tier A.

What is left. A settings block carries no program lines and no resource list yet, because nothing reads them from a lock, and its from section records the variables the evaluation read (2026-10-12) and the files (2026-10-21, #331): file "<path>", hash: by the SHA-256 of the bytes, directory:sha256: of the names in a directory, unreadable, or missing: true, sorted by path and relative to the package, and torb publish names them all. The first line of a written lock does not name torb lock, because changing it changes the tree hashes the package manager's transcript pins. A program "migrate" { ... } block is not added, as section 4 says it should not be until a setting needs it. Slice 9 is docs/design/RESOURCES.md's own plan.

The prose was rewritten with the slices: docs/tooling/project-trb.md, project-lock-trb.md, torb-build.md, torb-run.md, torb-test.md, torb-new.md, the-torb-command.md and the new torb-lock.md, docs/language/modules-and-packages/{packages,top-level-code,use,workspaces}.md, docs/standard-library/project.md, docs/glossary.md, the guide's first steps, modules and tests, the how-tos that build, test or add a dependency, and CONCEPT's project layout, its project.trb example and its decision log. docs/internals/index.md lists this document and docs/design/RESOURCES.md, so that docs check never sees a design document nothing links to.

13. What this is not#

  • Not the registry. How a registry answers - the index, the archives, publishing - is docs/design/RELEASE.md section 7. This document decides what is pinned and where a location is written, and section 6a what is resolved.
  • Not a build system. There are no rules, no targets that depend on targets, no code generation and no hooks. A project.trb describes a package; it does not describe how to make one.
  • Not a manifest that is a program you run. A project file is code and section 8 lets it read, which is one step towards Package.swift and exactly one: the grant is read-only, below one directory, plus the environment, with three modules and a step limit, and a published package carries a locked manifest that nobody evaluates at all. There is no way to write, to fetch, to spawn or to ask the machine what day it is, and there is no place to hang one.
  • Not a place for capabilities. What a script may do is granted at the call site that loads it — including when the caller is the toolchain and the script is project.trb. What a package may touch is visible from its imports. Neither is a setting.
  • Not a place for lint or format settings. There is no setting that chooses the rules of torb lint or the layout of torb format, and no file beside project.trb that does: every rule runs on every program, and every program is formatted the same, because the format is part of the language (CONCEPT, design principle 6; the owner, 2026-10-07).
  • Not a loader system, and not an asset list. docs/design/RESOURCES.md is written so that nobody has to reconstruct the argument, and neither an extension-loader table nor a resources: list is part of this design.
  • Not a path type. Every path in a project.trb and every specifier is a String the workspace reader interprets; std/path's Path is for files, and docs/design/PATH.md says why the two stay apart.
  • Not a change to what public means. A package's surface is still its public declarations, reached through "owner/name" and "owner/name/path". Nothing here adds a way to hide a module or to expose one twice.
  • Not a second manifest format, and not a third file. There are two files a project has: project.trb, which a person maintains, and project.lock.trb, which the toolchain writes and which holds both the locked settings and the pinned graph. build/manifest-inputs.trb is a build artifact, not a manifest. All of them are receiver scripts in the same vocabulary, and there is no .toml, no .json and no generated file that is not TorbScript.
  • Not a convention for a second program. There is no src/<name>/main.trb and no programs/<name>.trb. A second program is rare, and a convention that reserves a directory name in every package forever to shorten a rare line is a bad trade.

14. Open#

Everything technical above is decided, and so is the one naming question this document used to carry: the minimum-version setting is language (section 10). Nothing is open here.

Esc