Architecture
This page is for people about to contribute to Lattice’s own codebase, not for people using it in a monorepo. It names crates, modules, and types directly — the user-facing pages deliberately do not.
Crate layout
Lattice is a Cargo workspace of seven crates under crates/. Each one owns a
single concern:
| Crate | Owns |
|---|---|
lattice-config |
lattice.json types (LatticeConfig, WorkspaceConfig, PipelineTask, Settings, EngineSpec), WELL_KNOWN_ENGINES, load_config, find_root, schema validation |
lattice-workspace |
Workspace discovery, the driver evidence ladder (DRIVERS, detect_drivers), and (in its toolchain submodule) the engine gradient and toolchain provisioning |
dagger |
Builds the cross-workspace ExecutionGraph from resolved workspaces + the task map, and flattens it into a Schedule |
lattice-cache |
Cache identity (compute_key) and storage (the CacheStore trait, LocalStore) |
lattice-runner |
The async scheduler (execute_tasks): spawns tasks, wires the cache, injects toolchain PATHs, manages persistent tasks |
lattice-output |
OutputMode, TaskEvent, the Reporter trait (InteractiveReporter, CiReporter), and brand/splash rendering |
lattice |
The lattice binary: the clap CLI surface, subcommands, version-pin handover, the bundled JSON Schema |
The root lattice.json declares every crate plus apps/web as workspaces —
the repo dogfoods itself.
Dependency direction
Each crate’s Cargo.toml gives the real edges (verified, not assumed):
lattice-config
├── lattice-workspace (+ lattice-config)
├── lattice-cache (+ lattice-config)
└── dagger (+ lattice-config, lattice-workspace)
└── lattice-runner (+ lattice-cache, lattice-config, dagger,
lattice-output, lattice-workspace)
└── lattice (+ all of the above)
lattice-output — no internal dependencies
lattice-config is the base: every other crate models on top of its schema
types, and it depends on nothing else in the workspace. lattice-cache depends
only on lattice-config — compute_key needs a PipelineTask and a
filesystem path, not the workspace-discovery or graph machinery, so cache
identity can be reasoned about and tested in total isolation from how a task
came to exist.
Two edges are easy to get backwards from the crate names alone:
lattice-runner does not depend on petgraph. dagger’s Cargo.toml
lists petgraph = "0.8"; lattice-runner’s does not. dagger builds a
petgraph::graph::DiGraph<TaskNode, ()> internally, topologically sorts it,
then calls build_schedule to flatten it into a plain Schedule —
Vec<HashSet<usize>> prerequisites, Vec<Vec<usize>> dependents, a
Vec<usize> indegree count — indexed by position, not by petgraph’s
NodeIndex. The module doc in crates/dagger/src/lib.rs calls this a
“petgraph-independent Schedule”. lattice-runner only ever imports
dagger::{build_schedule, ExecutionGraph, Schedule}; its own scheduling loop
in execute_tasks (crates/lattice-runner/src/lib.rs) walks plain integer
indices and HashSet/Vec operations. One consequence: dagger’s own
Schedule unit tests (schedule_two_node_chain, cross_workspace_caret_edges,
…) exercise the scheduling shape without touching a live process, and the
graph library could be swapped without lattice-runner noticing.
lattice-output is a true leaf. Its Cargo.toml depends only on
console and indicatif — nothing else in the workspace. Every crate above
lattice-config may depend on it for reporting and branding, but it depends
on none of them back. This is what lets lattice-runner stay, in its own
module doc’s words, “deliberately I/O-only with respect to presentation”: it
emits typed TaskEvents and calls Reporter hooks (event, surface_failure,
run_summary, note, warn, finish) and never touches console,
indicatif, or println! for task status itself. InteractiveReporter (a
live indicatif::MultiProgress TUI) and CiReporter (plain greppable lines)
are the two implementations lattice_output::make_reporter picks between; a
third implementation would not need to touch the runner at all.
Data flow of lattice run
Tracing one invocation end to end, crate by crate:
- Entry (
crates/lattice/src/main.rs) —Cli::parse()(clap), thenCli::execute()(crates/lattice/src/cli.rs). - Version-pin handover — unless the subcommand is
upgrade,completions, orversion,Cli::executecallscrate::drift::repo_root()thendrift::honor_pin, which readslatticeVersiondirectly out oflattice.jsontext (not throughlattice-config, so a schema the running binary can’t parse still yields a pin) and, if a managed.lattice/binbinary doesn’t match, installs andexecs the pinned version instead. - Root find + config load (
crates/lattice/src/commands/run.rs) —lattice_config::find_rootwalks up from the cwd for alattice.json;crate::schema::ensure_schemawrites.lattice/schema.jsonif absent;lattice_config::load_configparses and callsLatticeConfig::validate. - Output mode —
crate::cli::detect_output_modeconsults the real TTY andCIenv vialattice_output::detect_mode. If the requested tasks’ transitive closure includes a persistent task (dagger::includes_persistent_task), an otherwise-Interactiverun is forced toRaw. - Workspace discovery + driver detection (
lattice_workspace::discover_workspaces) — for each configured workspace, resolves the merged engine map (lattice_config::resolve_engines), walks the evidence ladder (detect_drivers) forautoworkspaces, and resolves each root task’s command (infer_task_command) unless ascriptsoverride already supplies one. See Driver detection for the ladder itself. - Graph construction (
dagger::build_execution_graph_multi) — expands the requested (possibly stacked) root tasks across the resolved workspaces intoTaskNodes, wires^task/bare-taskedges, rejects cycles and a persistent task with a dependent, and topologically sorts the result into anExecutionGraph. - Schedule (
dagger::build_schedule) — flattens that graph into the petgraph-independentScheduledescribed above. - Scheduler execution (
lattice_runner::execute_tasks,crates/lattice-runner/src/lib.rs) — for each workspace up front, resolves its merged engines into aPATHprefix and identity string vialattice_workspace::toolchain::provision_and_resolve(memoized so an identical engine spec provisions once). Then it drives the in-degree scheduler: a task ready to run (indegree zero) is spawned as a Tokio task under aSemaphore-capped concurrency. Each spawned task (run_one) computes its cache key vialattice_cache::compute_key, looks it up through theCacheStore(lattice_cache::LocalStore), and either restores the cached artifact or runs the command through the platform shell (sh -c/cmd /C) with the resolvedPATHprepended. On success it stores outputs back through the sameCacheStore. See Cache internals and Engines and provisioning for those two subsystems in depth. - Event emission + reporting — every state change (
Started,CacheHit,Output,Finished,Failed,Skipped) is alattice_output::TaskEventsent over an internalmpscchannel and forwarded to theReporterchosen in step 4, which renders it as a live TUI line or a plain log line.
Tests
Tests live at two layers, plus one hermetic end-to-end script:
- Unit tests, colocated as
#[cfg(test)] mod testsat the bottom of the same file they cover.crates/dagger/src/lib.rscovers graph construction, cycle/persistent-leaf rejection, andScheduleshape directly.crates/lattice-cache/src/lib.rscovers hash stability/sensitivity and the store/lookup/restore/corruption/prune round trip.crates/lattice-workspace/src/lib.rscovers every rung of the evidence ladder and role composition/conflict.crates/lattice-workspace/src/toolchain.rscoversclassify,parse_version,satisfies, and a real provision-then-reuse round trip against a fake installed tool.crates/lattice-runner/src/lib.rscovers ordering, concurrency, fail-fast vs.--continue, the cache round trip, and persistent tasks not blocking the graph, all against aRecordingReportertest double.crates/lattice/src/cli.rsandcrates/lattice/src/drift.rscover the version-nag gating and pin-handover decision logic as pure functions. - Black-box e2e tests under
crates/lattice/tests/, each a#[test]binary that builds a throwaway repo in atempfile::TempDirvia the sharedFixturehelper (crates/lattice/tests/common/mod.rs) and drives the compiled binary throughassert_cmd.e2e_run.rscovers caching, filtering, dry-run, stacked/sequential phases, and keep-going.e2e_toolchain.rscovers provisioning andPATHinjection with a fakeinstallCmd.e2e_halves.rscovers running the task-runner and toolchain-manager halves independently, pluspruneandcompletions.e2e_init.rs,e2e_passthrough.rs, ande2e_upgrade.rscover scaffolding, wrapping a nested repo with its own runner, and version pinning. scripts/stress-test.sh— a single ~1000-line, dependency-free hermetic script that builds the release or debug binary, generates a production-shaped multi-language monorepo plus focused sub-repos in a temp directory, and exercises every command and flag against it: top-level surfaces,init,setup,run(ordering, filtering, dry-run, concurrency), caching, persistent tasks, failure handling, driver detection across roughly sixteen ecosystems, error paths,prune, nested-repo passthrough, and install/upgrade/pinning. It needs no network and no real language toolchains — driver detection is verified through--dry-run, and provisioning is exercised through a fake, locally-installed toolchain.AGENTS.mdrequires updating it alongside any change to CLI surface or behavior.
Extension seams
Where a contributor is most likely to make a change:
Adding a driver. Add a DriverSpec to the DRIVERS array in
crates/lattice-workspace/src/lib.rs: tool, role (Runtime, BuildTool,
PackageManager, or TaskRunner — this decides both drive rank and what it
composes or conflicts with), fingerprint files, version_cmd, and an
invoke_tpl with a {task} placeholder. If any fingerprint should be read as
a native declaration rather than a generic lockfile, add it to
is_native_fingerprint. Regenerate the driver table on
Toolchains from this array — never from memory.
Adding a well-known engine. Add the name to WELL_KNOWN_ENGINES in
crates/lattice-config/src/lib.rs, and add its built-in version command to
builtin_version_cmd in crates/lattice-workspace/src/toolchain.rs. Without
the second step a string-form constraint on the new name would pass config
validation but fail at resolution time with no way to check its version.
Adding a subcommand. Add a variant to the Commands enum in
crates/lattice/src/cli.rs, an Args struct plus execute method in a new
file under crates/lattice/src/commands/, and register the module in
crates/lattice/src/commands/mod.rs. Decide whether it needs the version-pin
handover (extend skips_pin_handover if not) and whether it needs the
version-drift nag (call maybe_emit_version_nag if so, as run.rs and
setup.rs do).
Adding a cache backend. Implement the CacheStore trait
(lookup, store, restore, prune, touch) from
crates/lattice-cache/src/lib.rs — LocalStore is the only implementation
today, and the trait exists so a future Lattice Cloud backend can slot in
without lattice-runner changing; execute_tasks holds its store behind
Arc<dyn CacheStore>.