Engines and provisioning

An engine is a versioned tool a workspace needs to run its tasks — node, rust, go, a linter, anything with a version that matters. You declare engines under the root engines key, a workspace’s own engines key, or both. Lattice never guesses a version policy for you: the shape of the constraint you write is the only thing that decides what Lattice does with it, and that shape alone selects one of three modes (crates/lattice-workspace/src/toolchain.rs:80-100).

Three modes, chosen by shape

You write Mode What happens
Nothing, or an empty object host PATH Trusts whatever is on PATH. Installs nothing, checks nothing.
A version constraint, no installCmd validate-only Runs a version command against the host tool and fails if it doesn’t satisfy the constraint. Installs nothing.
An installCmd provisioned Runs installCmd into a content-addressed directory, version-checks the result, pins it, and prepends its bin dir to the task’s PATH.

Nothing else — not the engine’s name, not whether it’s a well-known tool — changes the mode. Only the presence of a version constraint and the presence of installCmd do.

Host PATH: no constraint, no install command

{
  "engines": {
    "go": {}
  }
}

Lattice runs go tasks with whatever go resolves to on PATH. It never runs a version command and never provisions anything. This is also what happens if a workspace declares no engines at all — there is nothing to validate, so nothing is checked.

Validate-only: a version constraint, no install command

{
  "engines": {
    "node": ">=20.0.0"
  }
}

Before running any task that needs node, Lattice runs node --version on the host, parses the version out of the output, and checks it against >=20.0.0. If the host has Node 18, the run fails before any task starts:

engine 'node' 18.19.0 on PATH does not satisfy constraint '>=20.0.0'

(crates/lattice-workspace/src/toolchain.rs:283-286.) Nothing is installed — validate-only mode only ever checks.

Provisioned: an installCmd

{
  "engines": {
    "just": {
      "version": ">=1.30.0",
      "installCmd": "curl -fsSL https://just.systems/install.sh | bash -s -- --to \"$LATTICE_TOOLCHAIN_DIR/bin\"",
      "versionCmd": "just --version",
      "bin": "bin"
    }
  }
}

Adding installCmd is what opts an engine into provisioning — the version constraint and bin work the same as validate-only, but now Lattice runs installCmd itself, into a directory it controls, instead of checking whatever is already on PATH. The result is pinned so a second run reuses it instead of reinstalling. See Where a provisioned tool lives below for the exact layout.

String form versus object form

"node": ">=20.0.0" is the string form: a bare version constraint, nothing else. It only works for a fixed set of tools Lattice already knows how to version-check — WELL_KNOWN_ENGINES in crates/lattice-config/src/lib.rs:13 (node, rust, go, python, java, and the rest of that list; the full set is the Toolchains reference). Anything else must use the object form:

{
  "engines": {
    "protoc": { "version": ">=25.0", "versionCmd": "protoc --version" }
  }
}

The object form is { version, versionCmd, installCmd, bin }, all optional (crates/lattice-config/src/lib.rs:76-83). versionCmd exists because Lattice can’t guess how to check the version of a tool it doesn’t know — guessing would mean silently trusting an unverifiable claim, so it errors instead.

Writing the string form for an engine outside WELL_KNOWN_ENGINES is rejected when lattice.json is loaded, before any task runs:

engine 'protoc' in root uses the string (version-only) form, but 'protoc'
is not a well-known engine Lattice can version-check on its own. Use the
object form with an explicit `versionCmd`, e.g. "protoc": { "version":
">=1.0.0", "versionCmd": "protoc --version" }

(crates/lattice-config/src/lib.rs:304-316.) If you use the object form but still omit versionCmd for a tool Lattice has no built-in rule for, the failure moves to run time instead, when Lattice actually needs to check the version:

engine 'protoc' has a version constraint but no way to check it (not a
well-known engine and no `versionCmd`)

(crates/lattice-workspace/src/toolchain.rs:276-279.) Either way, an unknown tool with a version constraint needs an explicit versionCmd — there’s no path that lets Lattice skip it.

Where a provisioned tool lives

A provisioned engine installs under .lattice/toolchains/<engine>/, in a directory named after the version Lattice resolved and the first 8 hex characters of sha256(installCmd):

.lattice/toolchains/
  just/
    1.30.0-1a2b3c4d/
      bin/          # prepended to the task's PATH
      pins.json

While installCmd is running, Lattice hasn’t resolved a version yet, so it builds into a temporary tmp-<hash> directory first and renames it to <version>-<hash> only after the freshly installed tool passes its version check (crates/lattice-workspace/src/toolchain.rs:314-374). pins.json records what produced that directory:

{
  "engine": "just",
  "version": "1.30.0",
  "installHash": "1a2b3c4d",
  "bin": "bin"
}

(ToolchainPins, crates/lattice-workspace/src/toolchain.rs:111-118.) Before installing anything, Lattice looks for an existing <version>-<hash> directory whose pins.json matches the current installCmd hash and whose bin directory still exists — and, when a version command is available, re-checks that the installed version still satisfies the constraint. A match is reused; installation happens exactly once per distinct installCmd content (crates/lattice-workspace/src/toolchain.rs:194-240).

Hashing on the literal installCmd string, rather than on the engine name and version alone, means changing the install command — a different registry, a different flag — provisions into a new directory instead of silently reusing a stale one.

How installCmd sees $LATTICE_TOOLCHAIN_DIR

Lattice passes the target directory to installCmd two ways at once: as the LATTICE_TOOLCHAIN_DIR environment variable, and by substituting the literal string $LATTICE_TOOLCHAIN_DIR inside the command before running it (crates/lattice-workspace/src/toolchain.rs:325-330). That’s why the just example above works whether the installer reads an env var or expects the path spelled out on its command line — both resolve to the same directory.

Activation is per task

Provisioning and version resolution happen once per workspace, memoized: if two workspaces resolve to the same merged engine map, Lattice provisions it once and reuses the result for both (crates/lattice-runner/src/lib.rs:259-279). But activating a resolved toolchain — putting its bin directory on PATH — happens per task. Each task spawns its own child process, and only that child’s environment gets the provisioned bin directories prepended to a PATH cloned from the current one (crates/lattice-runner/src/lib.rs:588, crates/lattice-runner/src/lib.rs:739-748).

No shell is sourced and no profile is written — the PATH change lives only in the environment of the one process Lattice spawns for that task. Nothing about a provisioned engine touches your shell, your global tool installs, or any other task’s environment. It also means removing .lattice (or just .lattice/toolchains) is a complete uninstall: every provisioned tool lives under that tree and nowhere else.

Root and per-workspace engines

A workspace’s engines map merges with the root engines; the workspace’s entries win per key (resolve_engines, crates/lattice-config/src/lib.rs:319-325):

{
  "engines": { "node": ">=20.0.0", "rust": ">=1.75" },
  "workspaces": [
    { "name": "web", "path": "apps/web", "engines": { "node": ">=22" } }
  ]
}

web validates Node against >=22; every other workspace still validates against the root’s >=20.0.0; rust applies to web unchanged, since web never mentions it. This merge happens before any task in that workspace runs, whether by lattice run or lattice setup (crates/lattice-runner/src/lib.rs:266, crates/lattice/src/commands/setup.rs:72) — see Workspaces for how a workspace’s own engines key fits alongside path, auto, dependsOn, and scripts.

Where to go next

This page covers the model: what selects a mode, and what provisioning does on disk. For the full well-known-engine list and the built-in driver table, see Toolchains. For a task-oriented walkthrough of pinning a version day to day, see Pinning tool versions.