Computaco

Building worlds

World blueprints#

A world definition is a hermetic artifact. It is embedded in every native and Wasm build, its bytes are hashed by the determinism corpus, and a snapshot refuses to load into a world whose definition differs. That is what makes a world reproducible — and it is also what makes one miserable to write by hand. The reference company is three and a half megabytes of JSON.

A blueprint is the same document with a handful of source-only conveniences, resolved away before anything runs. cw-world reads the blueprint and writes the world:

cargo run -p cw-blueprint --bin cw-world -- build worlds/company-2026/world.yml

The result is an ordinary world definition. Nothing downstream knows a blueprint was involved.

Writing one#

A blueprint is YAML or JSON, and every key the world schema takes is a key a blueprint takes. A world that needs none of the conveniences below is already a blueprint — rename it and it builds unchanged.

schema_version: 1
id: agent-desktop

inputs:
  AGENT_USER:
    default: ada
    description: the workstation's user

profiles:
  - id: ubuntu
    name: Ubuntu 24.04 workstation
    family: linux
    home: /home/{user}
    shell: posix

computers:
  - id: workstation
    profile: ubuntu
    address: 10.0.0.10
    user: ${AGENT_USER}
    copy:
      - from: home
    installed_apps: [terminal, browser, files, editor]

services:
  - id: wiki
    kind: wiki
    node: intranet-server
    domains: [wiki.internal]
    initial_state: {brand: Team wiki}
    place:
      address: 10.0.1.10
      zone: local
      link: {from: workstation, latency_us: 10}
      dns:
        local: {ttl_us: 60000000, resolver: intranet-server}

Every path is relative to the blueprint's own directory and may not climb out of it, so a world is built from the files that travel with it and from nothing else. A symbolic link is refused rather than followed.

computers/ — one directory per machine#

A computer can be declared inline, as above, or in a file of its own. A world's machines read best as a directory each:

world.yml
world.json
computers/
  carol-ubuntu/
    computer.json
    root/home/carol/notes.txt

computer.json is one computer on its own — recognised, when included, by its profile, which no world document has — and root/ beside it is that machine's filesystem: root/home/carol/notes.txt is /home/carol/notes.txt on it, and so ~/notes.txt. On a Windows machine the top directory is the drive, so root/C/Users/bob is C:/Users/bob. A directory may name an input, as in root/home/${AGENT_USER}, and substitutes like any other string.

include:
  - computers/carol-ubuntu/computer.json
  - computers/app-server/computer.json

Include them in the order they should appear. A glob such as computers/*/computer.json works too, and orders them by name.

root/ is a copy: entry with to: /, inserted ahead of any the computer states, so the rules below apply to it.

copy: — a machine seeded from real files#

initial_files is the right shape for the engine and the wrong shape for a person: a seeded home folder written that way is a JSON string literal with \n in it, undiffable and unopenable. copy: names directories instead.

    copy:
      - from: home/all
      - from: home/ubuntu
        to: "~"
        exclude: ["*.tmp"]

Entries overlay in order, so home/all then home/ubuntu reads the way it is written: where both hold the same path, the second wins. to: defaults to ~, the user's home folder, which is where a relative initial_files path already resolves; an absolute to: such as /etc seeds an absolute path, and to: / mirrors the machine as root/ does above. A file whose bytes are valid UTF-8 lands in initial_files, and anything else is base64 in initial_binary_files. .DS_Store, .gitkeep and .git are never seeded.

A file stated in the blueprint beats one copied in bulk, so an exception does not have to be deleted from the directory to be stated. Seeded paths are sorted, and the whole set takes the position the copy: block held, so the resolved computer reads in the order it was written.

There is a ceiling of 3 MiB of seeded bytes per computer, because the world file rides in every build. Raise it deliberately:

limits:
  bytes_per_computer: 8388608

Why the copy happens at build time#

It would be more convenient to copy files in after a machine boots. It would also be wrong in three separate ways, and each of them is silent:

  • Runtime::reset restores the definition's baseline, so files written after construction vanish at the first reset.
  • Snapshot import guards on definition equality. Files outside the definition make two unlike worlds compare equal, and a snapshot from one loads into the other without complaint.
  • The Wasm build has no filesystem to copy from, so a world that seeded itself that way could never run in the browser.

Folding the bytes into the definition is what keeps all three honest.

include: and extends: — composing documents#

include: merges fragments after the document's own declarations. extends: loads another blueprint first and merges this one over it. Both obey one rule: a later contribution replaces an earlier one, in the position the earlier one held. Position matters because the world file is checked in — appending a service rather than replacing it in place would reorder every service after it.

extends: base/world.yml
include:
  - services/*/service.json
  - devices/*.yml

A pattern may use * (within one path segment), ** (across segments) or ?; matches are sorted, so a glob builds the same world twice. A pattern that matches nothing is an error, as is a cycle.

Lists merge by identity where they have one — profiles, computers, services and network.nodes by id, network.dns by name, network.links and network.routes by their from/to pair. Every other list is replaced whole, so installed_apps: [terminal] means those applications and not those on top of whatever was there.

A fragment is a whole world document, one service on its own, recognised by its kind, or one computer on its own, recognised by its profile — keys no world document has. That is what lets a directory of service files be a directory of service files, and a machine be a directory of its own. A service.json or computer.json must declare the id its directory is named for.

A file named overlay.json is this world's content on an internet service: services/github/overlay.json becomes internet_overlays.github, so a world's services — the ones it runs and the ones it only has accounts on — are one directory each under services/:

include:
  - services/intranet/service.json
  - services/*/overlay.json

A key a fragment brings that the blueprint does not state takes the place the world schema gives it, so a blueprint whose computers all come from their own files needs no computers: [] to hold that place open.

from_file: — a value that lives in its own file#

Anywhere a value belongs, {from_file: <path>} becomes that file's contents:

    initial_state: {from_file: shop/catalog.json}

A service that has a default needs none of this: a wiki with no articles starts with its own Main Page, and a search engine with no documents indexes the sites beside it. Nor does the public web belong in a blueprint: it is worlds/internet, joined to every world that does not set internet: false. Reach for from_file when the content really is the world's own.

Substitution does not reach inside a loaded file: data is data.

from_dir: — a value that is a directory#

A service with accounts holds more than a JSON file should: a mailbox's attachments are files, and writing them as base64 inside overlay.json is the undiffable string literal copy: exists to avoid. {from_dir: <path>} becomes a mapping with one entry per file, a subdirectory becoming a mapping of its own:

services/google-mail/
  overlay.json
  attachments/mail-1/rollback-plan.md
  attachments/mail-1/launch-owners.csv
"mail-1": {
  "subject": "Atlas launch checklist",
  "attachments": {"from_dir": "attachments/mail-1", "as": "base64"}
}

as: says how each file is read. base64 keys each file by its whole name and encodes its bytes, which is how a service takes a binary file; text keys it by its whole name and requires UTF-8; data, the default, requires JSON or YAML and keys each document by its name without the extension, so a directory of messages/mail-1.json files can stand in for a messages mapping. Entries come out sorted, and .DS_Store, .gitkeep and .git are skipped.

A from_file or from_dir path is relative to the file that names it, so a service's directory carries its own data; it may climb with .. within the blueprint's directory but never out of it. include:, extends: and copy: paths stay relative to the blueprint's root.

Stating a service's placement three times is what makes a large world unwritable. The reference company has ninety-three services and paid for them with a hundred and two nodes, a hundred and two links and two hundred and thirty DNS records, none of which said anything the service had not already implied.

defaults:
  place:
    zone: internet
    link: {latency_us: 1500}
    dns:
      internet: {ttl_us: 300000000, resolver: dns-public}
      local: {ttl_us: 60000000, resolver: app-server}

Each service then says only what is its own:

  - id: airbnb
    kind: shop
    node: airbnb
    domains: [airbnb.com, www.airbnb.com]
    place:
      address: 198.51.100.112
      link: {from: pop-west}

which adds the node, the link from pop-west, and an address record for each domain with the TTL and resolver its zone declares. A record stated by hand wins: an alias to another name is exactly what a derived address record would destroy. Omit link: and no link is made. DNS records come out in name order, so adding a service moves one line rather than appending to a list nobody can scan.

inputs: — the environment, declared#

A blueprint may not reach into the ambient environment. Every variable it substitutes is declared up front, so cw-world inputs can say what a world needs before it needs it, and so nothing depends on the machine that built it.

inputs:
  COMPANY_DOMAIN:
    default: northstar.example
    description: the domain the intranet answers on
  REGION:
    default: us
    one_of: [us, eu]
  BUILD_TAG:
    optional: true
  FIXTURE_ROOT:
    secret: true

${NAME} and ${NAME:-fallback} substitute into any string, keys included. Values come from the environment, or from --set NAME=VALUE, which wins.

  • An undeclared name is an error, and the message lists what is declared.
  • A declared name with neither a value nor a default: is an error, and every unsatisfied input is reported at once — being told about one missing variable per run is how a five-variable world takes five runs to build.
  • An optional: input may be unset, and then every ${NAME} reading it must carry its own fallback. Substituting an empty string is how a world quietly builds wrong.
  • A secret: input may never reach the resolved world. It is usable in a source path such as a copy.from, and refused anywhere it would be written into the artifact — a host token baked into an actor-visible file is the leak this prevents.
  • $${ is a literal ${. Beyond that, ${...} substitutes only when what it wraps is a name or a name with a fallback, so a seeded workflow's ${{ matrix.os }} and a shell script's ${1} survive unchanged.

Only the blueprint the build was pointed at declares inputs; a fragment may use them. One file therefore tells you everything a world needs to be built.

cw-world#

cw-world build     <blueprint>   resolve it and write the world definition
cw-world check     <blueprint>   resolve it and verify the written world is current
cw-world validate  <blueprint>   resolve it and report, writing nothing
cw-world inputs    <blueprint>   list the inputs it declares

-o <path> chooses where to write (default: world.json beside the blueprint), --root <dir> chooses the directory paths resolve against (default: the blueprint's own directory), and --set NAME=VALUE supplies an input.

build and validate both run the engine's own validation — duplicate identities, unknown profiles, address and listener collisions, DNS shape — so a blueprint fails at build time rather than at World::new. An unknown key is refused too, and the message names the key it was probably meant to be: a blueprint that silently drops a misspelled intial_files is worse than no schema.

check is what CI runs, through scripts/test-all.sh: a hand-edited world.json would be overwritten by the next build, so the drift is caught instead.

Two worked examples#

worlds/agent-desktop/world.yml is one machine, one service and one input, and builds worlds/agent-desktop/world.json.

worlds/company-2026/world.yml is the reference company, resolving to the two-megabyte world file beside it: five machines under computers/, each seeded from its own root/, the services the company runs each in a services/<id>/service.json that places itself, and its content on seventy-three of the internet's services in their services/<id>/overlay.json.