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- 1. What the toolchain does today — twenty-one probes, and four places where the docs promise more than the code does
- 2. What other systems do — nine of them, and what we take from each
- 3. What a package produces — one library, one program for free, the rest configured
- 4. Programs —
program, its three settings, and whatrunandbuilddo with them - 5. Profiles and targets — two words for two things, and where the binary lands
- 6. The specifier grammar — what may stand in the quotes, and what is reserved
- 6a. Requirements and resolution — the text after
:, pre-releases, and PubGrub, as built - 7. Where a dependency comes from — names in source, locations in the manifest
- 8. What a project file may read — files and the environment, and the locked manifest that keeps installing free of code
- 9. Resources, as far as the project file is concerned — the output layout and the file list;
docs/design/RESOURCES.mdhas the rest - 10. The vocabulary — every setting, its type, its default, and whether it is read statically
- 11. Workspaces and the lock file — what a member inherits, what the lock pins
- 12. Migration — nine slices, each green on its own
- 13. What this is not
- 14. Open
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 package | Result |
|---|---|---|
| 1 | src/main.trb and src/other.trb, both with top-level code | 3 files, no problems |
| 2 | src/main.trb plus src/server/main.trb, both with top-level code | 3 files, no problems |
| 3 | src/main.trb and src/lib.trb in one package | 3 files, no problems |
| 4 | build { input = "src/other.trb" } next to a src/main.trb | 3 files, no problems; the build emits #line 1 "probe/other-input/src/other.trb" |
| 5 | build { input = "src/server/main.trb" } | the build emits #line 1 "probe/second-program/src/server/main.trb" |
| 6 | no build { } at all, one src/main.trb | 2 files, no problems; the build writes build/release/program.c |
| 7 | only 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 |
| 8 | use File from "std/fs" with an empty dependencies | 2 files, no problems |
| 9 | use greet from "probe/lib" where probe/lib is a workspace member and not a dependency | error: The package `probe/lib` is not a dependency of `probe/app` |
| 10 | the same with dependencies { runtime "probe/lib" } | 5 files, no problems |
| 11 | dependencies { runtime "acme/http:^1.2.3" } for a package that exists nowhere, never imported | 2 files, no problems |
| 12 | name = "probe-bare" — no owner | 2 files, no problems |
| 13 | use X from "https://example.test/x" | error: There is no package `https:/` |
| 14 | use X from "github.com/project/x" | error: There is no package `github.com/project` |
| 15 | use logo from "./logo.png" next to a real logo.png | error: There is no module `./logo.png` |
| 16 | use logo from "./logo.png" next to a file named logo.png.trb | 3 files, no problems — it resolves |
| 17 | use marker from "probe/lib/main" — another package's src/main.trb, which has top-level code | 6 files, no problems — it resolves |
| 18 | build { input = "src/lib.trb" } on a library whose src/lib.trb has top-level code | 5 files, no problems; without the build line the same file is error: Top-level code is only allowed in entry files |
| 19 | a parameter of a user type, called with a string literal | error: Expected `Resource`, found `String` — a literal adapts to nothing today |
| 20 | Sandbox.load<ServerConfig>("./nowhere/at/all.trb"), and the same with a String variable | 2 files, no problems — the checker reads the path not at all |
| 21 | the same program on stage 0 and through the native back end | stage 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 workspaceerror: 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 extensionTwo 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"insrc/main.trb, with a realtools/helper.trb, answerserror: There is no module `../tools/helper`. The package's modules aresrc/**plus whatevertest { input }names, and nothing else. Fixed 2026-09-27 by section 3's rule: a package's files are every.trbfile 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/*"answerserror: 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#
- "Nothing outside a package can name its
src/main.trb."docs/language/modules-and-packages/packages.mdrule 2. Probe 17 imports one. Worse, thatsrc/main.trbis 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). - "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-fiveproject.trbfiles write — makessrc/lib.trban 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:isEntryFileasks the manifest,isLibraryModulehardcodessrc/lib.trb, andisEntryFileis asked first. Fixed 2026-09-27: the names of the files decide,src/lib.trbis never an entry, and removing the thirty-two blocks found no top-level code in any library. - "A name is
owner/name."packages.mdrule 1 andstd/project's own doc comment. Probe 12: a bare name is accepted everywhere, and the static reader has no validation of any kind. build { target, output }andtest { input }.torb buildhardcodes the profile name"release"and the path<project>/build/<profile>/<name>;torb testhardcodes the directorytests.outputis read by nobody —compiler/project.trbwritesoutput = "build/{target}/torb"and the binary is not calledtorb. 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 tobuild/<profile>/<program>or to the literaloutputof itsprogramline, andtorb testtests 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#
| System | Several programs in one package | One library or many | Where versions and locations live | How a non-code file gets in | What the lock pins |
|---|---|---|---|---|---|
| Cargo | src/main.rs, src/bin/*.rs, [[bin]] tables | exactly one [lib] | Cargo.toml; git/path/registry written on the dependency line | include_bytes!/include_str!, relative to the file; nothing lists them | name, version, source, checksum, per package |
| Go modules | every directory whose package is main, by convention cmd/<name>/ | many — every directory is an importable package | the import path is the location, host-qualified; go.mod lists modules and versions | //go:embed on a variable, patterns relative to the file's directory | go.sum: a hash per module zip and per go.mod |
| Deno | any file with top-level code; named tasks in deno.json | not a package model at all | once: the URL in the import. Now: deno.json's imports map and jsr:/npm: bare specifiers | an import attribute (with { type: "text" }), or a run-time read under a process permission | deno.lock: an integrity hash per specifier |
| JSR | — | one package with an exports map of several entry points | deno.json/jsr.json, resolved through the import map | — | — |
| Node, bundlers | a bin map in package.json | one package, an exports map | package.json, plus npm:/file:/git: protocols inside the version field | a loader chosen by file extension, configured in the bundler | name, version, resolved URL, integrity |
| SwiftPM | products: [.executable(...)], each over targets | any number of library products | Package.swift, which is a Swift program | resources: per target, copied or processed, reached through Bundle.module | Package.resolved: a revision per package |
| Zig | one b.addExecutable per program in build.zig | b.addModule | build.zig.zon: a URL and a hash | @embedFile, relative to the file | the hashes in build.zig.zon |
| Dart, pub | bin/<name>.dart | one lib/, with lib/src/ private by convention | pubspec.yaml; git:/path: on the dependency | an assets: list in pubspec.yaml | pubspec.lock |
| Gleam | src/<name>.gleam with a pub fn main | one src/ | gleam.toml and a generated manifest.toml | files under priv/, copied | hashes 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/xas a name. Not its "a program is a directory with amain", 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.tomlis the size aproject.trbshould be. - From Zig:
@embedFile's shape — a file named by a literal that the compiler resolves. That isdocs/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.
binmaps,products:, and[[bin]]for the first program. A list in the manifest of things the directory already shows is a second source of truth, andbuild { input }is that mistake one program small.src/main.trbneeds no line, and that is the case that is not rare.Package.swiftandbuild.zig— a manifest that is a program you run, with the machine's whole surface under it. Aproject.trbis 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
exportsmap (JSR, Node)."owner/name/path"already is the surface, andpublicalready 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.mdsays 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.trbmakes the package a library. It is what"owner/name"resolves to, and"owner/name/path"issrc/path.trbof 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.trbis the program, named after the package, and it needs no line in the manifest.- Every further program is a
programline (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.trbis a test entry, anywhere in the package. - Every other
.trbfile undersrc/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 file | Top-level code | Importable |
|---|---|---|
src/lib.trb | no | yes, as "owner/name" |
src/<path>.trb | no | yes, as "owner/name/<path>" |
src/main.trb | yes | no |
the entry of a program line | yes | no |
*.test.trb | yes | no |
a receiver script (project.trb, anything the sandbox loads) | yes | no |
a .trb file with no project.trb above it | yes | it 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 mistake | Where it is caught | The message |
|---|---|---|
two program lines with one name | the static reader, before anything is checked | error: 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 default | the static reader | allowed — 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 entry | the static reader | error: `torb` and `cli` both rename the default program |
program "torb" with no src/main.trb | the workspace reader, once it has the tree | error: `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 exist | the workspace reader | error: The entry `tools/migrate.trb` of the program `migrate` is not a file |
an entry outside the package directory | the workspace reader | error: The entry `../tools/migrate.trb` of the program `migrate` is outside `acme/shop` |
entry: "src/lib.trb" | the workspace reader | error: `src/lib.trb` is the library of `acme/shop`, so it cannot be the entry of a program |
an entry that some module imports | the module graph, at the use | error: `../tools/migrate` is the entry of the program `migrate`, so nothing can import it |
| two programs that resolve to one output path | the build, per profile | error: `migrate` and `importer` both build to `dist/tool` |
an output that is interpolated | the static reader | the 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 elsetorb runwith no name runs the package's only program. "Only" countssrc/main.trbplus everyprogramline that has anentry; aprogramline without anentryis 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, migratetorb run <name>runs that program. A name nothing declares lists the ones that exist.torb buildbuilds every program of the package, in the order the manifest declares them withsrc/main.trbfirst. 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 buildonstd/textchecks it and saysacme/lib is a library: checked, nothing to build. That matters most in a workspace, wheretorb buildat 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>andtorb 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 keepsexamples/tourandtests/conformance/legal: they declare noprogramat all, so they have no products, andtorb 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 runwithout an argument runs the only program below the working directory.torb run migratelooks the name up among the programs below it; a name nothing declares answerserror: 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 answerserror: acme/lib is a library: there is nothing to run, and a package of scripts - neithersrc/main.trbnorsrc/lib.trb-error: acme/tour has no program: there is nothing to run. Both in the VM and with--native.torb buildbuilds every program whose entry lies below the path (default.): per packagesrc/main.trbfirst, then theprogramlines in order, and at a workspace root every program of every member. Where there is none it checks and says why, and leaves with0:acme/lib is a library: checked, nothing to build,acme/tour has no program: checked, nothing to build, or for several packagesno package below <path> has a program: checked, nothing to build.--outputand--emit-care for one program: with several the "Name one" error comes with`--output` is for one program. Two programs that resolve to one path answererror: `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.trbsaysprogram "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
devandrelease, 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
wasmand 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 whatnativeandforeignalready 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 hostprofile "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.
componentforbids a dot, so"./logo.png"is a grammar error with its own message instead of resolving tosrc/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 keepsuse logo from "./logo.png"unavailable — whichdocs/design/RESOURCES.mdlists 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 noteA 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/geometryand 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 undersrc/, it may not contain.or.., and its last component may not bemain— a program is not importable (section 3). - An owner that contains a dot is host-qualified and is reserved.
github.com/project/xparses as the name it looks like and answers a message that says what it would mean, rather thanThere 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 shapeword:. 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:
| Where | What it accepts | Relative to |
|---|---|---|
use ... from "..." | relative and package | the file |
dependencies { runtime "..." } | owner/name, optionally followed by ":" requirement — never a module path | nothing |
source "...", ... | the same owner/name | nothing |
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 written | the 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.3is>=1.2.3 <2.0.0,^0.2.3is>=0.2.3 <0.3.0,^0.0.3is0.0.3alone,~1.2.3is>=1.2.3 <1.3.0, and a partial version stands for the range it leaves open (=1.2is>=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.1allows1.2.3-beta.2and every release up to 2.0.0, and not1.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+buildwould 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:
| Candidate | For | Against |
|---|---|---|
| PubGrub — decided | the 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" sentences | the most code of the three, and the error writer is half of it |
| Minimal version selection (Go) | no solver at all; reproducible without a lock | picks 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) | small | exponential 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 | |
|---|---|---|---|
| Reproducibility | one requirement per package, in one file | two files can name two versions of one package; nothing makes them agree | one requirement per package, in one file |
| Reviewing an update | one diff in project.trb and project.lock.trb | a grep across every source file | the same as (A) |
| Offline builds | the lock plus a cache answers it | "what do I have to vendor" is a source-wide query | the same as (A) |
| A fetch at compile time | never: resolution reads the lock | the checker resolves a URL, so the type checker needs the network | never |
| Where the location is visible | in one file, next to the requirement | on the import line, where it is used | in the name, everywhere it is used |
| Namespace trust | the registry binding, which is what stops dependency confusion | whoever holds the host name | the host, which is a weaker promise than a registry but a real one |
| Sandbox capabilities | unaffected — they are granted at the call site | unaffected | unaffected |
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/fsis the file reading;std/textis what you do with the text you read (trim,lines,split);std/os/environmentisEnvironment-get(name): String?, the listing and the search path - and nothing else ofstd/os.std/timewould be a clock,std/processa shell,std/httpa 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, anddocs/design/PATH.mdsection 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. SoFile.readText("../../etc/passwd")is aSandboxErrornaming the path and the root, and so is aVERSIONthat 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_NUMBERshare 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.
- Whatever flows from the environment into a setting is published with the package.
versionends up in the locked manifest below, which is the file a registry, a consumer and an editor read. A manifest that writesdescription = 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), andtorb publishprints 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. - 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.trbboth 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"
}settingsis the evaluatedProject, printed back as the command calls that would produce it, in a fixed order, every argument a plain string or number literal. This isdocs/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.settingsnames its package, so a workspace root's lock has one block per member. That is what CONCEPT's "oneproject.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 onesettingsblock and nograph.settingscarries what a consumer needs and nothing else:language,version,prelude,dependencies, theprogramlines, 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 nosourceand noregistry— where a package comes from is the building project's decision, and a dependency that could republish its ownsourcelines would be dependency confusion by another route — and noworkspace, which is about a tree the consumer does not have, and noprofileortest, which are about a build the consumer is not running.fromis 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 ofbuild/manifest-inputs.trb, which has the same two line shapes.graphis what CONCEPT's lock file already pins (section 11), unchanged.- A published package carries both files, whole.
project.trbtravels so that a person can read how the version was computed and what the author actually wrote;project.lock.trbtravels 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. Thegraphsection 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 publishverifies 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.trbis not installable. There is nothing a consumer may read, and evaluating a foreignproject.trbis the one thing this design does not do.torb publishwrites 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; onesettingsblock 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;fromentries by kind and then by name;graphpackages by name;programlines in the order the manifest declares them, which is the one order that is itself meaningful (section 4:torb buildbuilds 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 formtorb canonalready defines for a literal; strings withcanon's escapes; UTF-8, LF, no BOM, exactly one trailing newline. - How it is checked.
torb lock --checkwrites the file into memory and compares it byte for byte with the one on disk, failing with a diff — which is exactly the shape ofcanon --checkanddocs 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 compiledtorbmust 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 --checkfails witherror: project.lock.trb is not what `torb lock` writes, the first ten lines that differ asline N: on disk `...`, written `...`and the command that writes it;tools/gates.sh aruns it over everyproject.lock.trbgit 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 package | What the toolchain does | The environment |
|---|---|---|
| the project itself | its project.trb is evaluated, scoped to its own directory | yes |
| a workspace member | its project.trb is evaluated, scoped to the member's directory | yes, the same one |
a path: source | the same, scoped to its directory | no |
a git: source | the same, scoped to the checkout — it is source you chose to build from | no |
| a registry package | never 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:
settingsis written bytorb publish, bytorb pack, and by an explicittorb lock. Nothing else writes it.graphis written bytorb add,torb removeandtorb update, exactly as CONCEPT already says.torb run,build,testandcheckreadgraph, never write it, and refuse when it does not matchproject.trb— also exactly as CONCEPT already says — and they ignoresettingscompletely.
Errors, determinism and caching#
A failure of the script is a diagnostic against
project.trb, with the line theSandboxErrorcarries, never a toolchain crash — which is the reason for a sandbox rather than aninclude. 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
Projectalready 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
settingssection 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.gitignorealready 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"answeringNoneis a read whose answer changes the moment somebody sets it, sounset: trueis 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.trbis rewritten by every build and read by the next one, andfrom { }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 (
manifestProblemsOfofcompiler/src/project/manifest.trb, a problem of the workspace): the nine static rows,programamong them, whoseentrynames an entry file. It is never evaluated. - A setting the toolchain uses that is computed is evaluated:
version,tasks { ... }, aprofileblock, or a line that is no setting at all - anif, a loop - which may configure anything (needsEvaluation). The build,torb runandtorb testevaluate that manifest and readversion,tasksand the profiles from the settings it answers (manifestOfProjectofcompiler/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 ofproject.trb. - What is computed and read by nobody -
description,license, aconst- 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.trbkeeps what the evaluation read, as the sandbox recorded it (the grant'srecord), 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 fileA
programline'soutputmoves both:output: "dist/migrate"isdist/migrateanddist/migrate.resources/.The published package's file list. The
settingssection ofproject.lock.trb(section 8) carries every resource path the package's own code names, asresource "..."lines, so a consumer can see the non-code files of a dependency without evaluating anything andtorb publishcan refuse a package whose code names a file the archive does not contain.The one thing
project.trbsays 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
EmbeddedBytesandEmbeddedTextthat 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()and64.megabytes(), argued from measurement (docs/design/RESOURCES.mdprobe 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 — anassets/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 buildalready 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 addreads them, and they are about your build rather than about the package, so they are not in the lock'ssettingseither — the same placeprofileandtestsit.megabytes()moved.kilobytes,megabytesandgigabytesonInt64lived instd/sandbox, whereSandboxCapabilities.limitsneeds them. They are instd/numbernow and answer aByteSize(OS slice 2), so that aproject.trbcan write4.megabytes()with no import and there is exactly one definition for both callers. The tworesourcessettings areByteSizes for that reason.Nothing else in
project.trbconfigures a resource, and a budget is not the exception it looks like. Noassets:list, no loader table, no per-file setting. The objection this design makes to SwiftPM'sresources: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.
| Setting | Type | Default | Static | Error |
|---|---|---|---|---|
language = "0.3.0" | String | none | yes | an older toolchain refuses the project, and says so before reading anything else |
name = "owner/name" | String | none | yes | missing, not owner/name, or not a plain string |
prelude = "std/prelude" | String | "std/prelude" | yes | not a package specifier; the package must exist |
dependencies { runtime "..." } | variadic | none | yes | not owner/name[:requirement] |
dependencies { development "..." } | variadic | none | yes | the same |
source "owner/name", git:/path:/archive: | method | the owner's registry | yes | two source lines for one package; git: without revision:; archive: without hash: |
registry "owner", url: "..." | method | none | yes | two registries for one owner |
workspace { members "..." } | variadic | none | yes | a pattern that matches no project; a member without a project.trb |
program "name", entry:, output: | method | src/main.trb is the one program | yes | section 4's table |
version = "1.4.0" | String | the workspace root's | no | not a version; required to publish |
authors "..." | variadic | the workspace root's | no | — |
keywords "..." | variadic | none | no | at 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 | "" | no | required to publish |
license = "MIT" | String | "" | no | required to publish |
repository = "https://..." | String | the workspace root's | no | — |
profile "release" { ... } | method | the built-in profiles | no | an unknown profile name |
test { coverageThreshold = 80 } | Int | 0 | no | outside 0..100 |
resources { embeddedWarningAbove } | ByteSize | 4.megabytes() | no | negative, or above embeddedErrorAbove |
resources { embeddedErrorAbove } | ByteSize | 64.megabytes() | no | negative |
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 toolchainBoth 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.
| Ecosystem | What it is called | What it versions |
|---|---|---|
| Rust | rust-version = "1.75" in [package] | the toolchain: compiler plus std |
| Go | go 1.21 in go.mod | the language version and the toolchain, in one line |
| Node | "engines": { "node": ">=18" } | the runtime only; the compiler is somebody else's |
| Dart | environment: sdk: '>=3.0.0' in pubspec.yaml | the SDK: compiler, runtime and core libraries |
| Swift | // swift-tools-version:5.9, a comment on the first line of Package.swift | the 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:
| Candidate | For | Against |
|---|---|---|
language = "0.3.0" — decided | names 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 up | it 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 installed | it 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 out | the 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 comparison | silent about the minimum version of what, which is the only interesting part |
requires = "0.3.0" | short | reads 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 read | The file | How |
|---|---|---|
the project torb was invoked in | its project.trb | statically; then evaluated if a setting is not a literal |
| a workspace member | its project.trb | the same |
a path: or git: dependency | its project.trb | the same, without the environment |
| a registry package | its project.lock.trb, settings section | statically, always — its project.trb is never parsed by the toolchain |
| the dependency graph of the build | the root's project.lock.trb, graph section | statically |
| an editor or a registry looking at a foreign package | its project.lock.trb, settings section | statically |
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 computedThat 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.
nativeandforeign. They are keywords in source, so they are visible where they are used, andtorb addreads 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,outputas a whole-project setting,target. Sections 3, 4 and 5.- An exports list.
publicand"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;
entryis 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
matcharm 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 astatementinstruction (docs/design/DEBUGGER.mdsection 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 thestatementinstructions 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 of0, the default, changes nothing.torb test --nativewith a threshold above0is an error: a binary has nostatementinstructions 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 jsoncarries 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:
| Source | Pinned |
|---|---|
| a registry | the 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.
| # | Slice | Files | Risk |
|---|---|---|---|
| 1 | The 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 testInput | compiler/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.trb | Medium. 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 |
| 2 | The 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 coverageThreshold | std/project/src/lib.trb, every project.trb, docs/standard-library/project.md | Low, and it is the slice that proves slice 1, because nothing may change behaviour |
| 3 | Programs. 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.trb | Low. 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 |
| 4 | Profiles 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 literally | compiler/src/cli/build.trb, std/project/src/lib.trb, compiler/src/project/manifest.trb | Low. buildTarget = "release" is one constant today, and the layout already has the shape |
| 5 | The 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 package | compiler/src/semantics/graph.trb, compiler/src/semantics/scope.trb, compiler/tests/check.test.trb | Low, and it is the slice with the most new diagnostics, so it is mostly tests with exact messages |
| 6 | Sources 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, repository | std/project/src/lib.trb, compiler/src/project/manifest.trb, compiler/tests/project.test.trb | Low on its own. It does not resolve anything — resolution needs the registry protocol, which is CONCEPT's open question |
| 7 | The 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 script | compiler/src/project/*, compiler/src/cli/*, std/sandbox, std/os, the VM | Done 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 |
| 8 | The 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.trb | std/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 |
| 9 | Resources. docs/design/RESOURCES.md's slices, which are a plan of their own | see that document | see 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").
Manifesthas nobuildInputand notestInput, a package is its whole directory,src/lib.trbis never an entry, andtorb testwithout a path tests the package it is run in. Removing the thirty-twobuild { input = "src/lib.trb" }blocks found no top-level code in any library. - Slice 2: no
project.trbof the repository writesbuild { }ortest { input }.Buildis gone fromstd/projectandTestkeepscoverageThresholdalone, which nothing reads yet; a manifest that still writesbuild { }does not check, becauseProjecthas nobuild. - Slice 3:
ProgramandProject.program, the static reader'sprogramlines,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.trbsaysprogram "torb". - Slice 4: the
profileblock,ProfileandProject.profile,-O<optimize>and-g, inheritance from the workspace root,build/<profile>/<program>andbuild/<target>/<profile>/<program>, and a literaloutput(section 5, "As built").torb buildkeptreleaseas its default, which section 5 decides and argues. - Slice 5:
specifierOfincompiler/src/package/specifier.trbtakes 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,licenseandrepositoryare instd/projectand in the static reader, and the plain-string rule of the nine static settings (manifestProblemsOf) has tests forsource,registry,languageandprogrambesides the others. - Slice 8:
Lockwith itssettingsandgraph, the deterministic printer,torb publishwriting thesettingsblock from the evaluation and carrying both files in the archive, the consumer side reading a dependency'ssettingsinstead of itsproject.trb, andtorb 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.trbdescribes 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.swiftand 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 lintor the layout oftorb format, and no file besideproject.trbthat 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.mdis written so that nobody has to reconstruct the argument, and neither an extension-loader table nor aresources:list is part of this design. - Not a path type. Every path in a
project.trband every specifier is aStringthe workspace reader interprets;std/path'sPathis for files, anddocs/design/PATH.mdsays why the two stay apart. - Not a change to what
publicmeans. A package's surface is still itspublicdeclarations, 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, andproject.lock.trb, which the toolchain writes and which holds both the locked settings and the pinned graph.build/manifest-inputs.trbis a build artifact, not a manifest. All of them are receiver scripts in the same vocabulary, and there is no.toml, no.jsonand no generated file that is not TorbScript. - Not a convention for a second program. There is no
src/<name>/main.trband noprograms/<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.