RFD0014 - Riot Dependency Classes
- Feature Name:
riot_dependency_classes - Start Date:
2026-03-20 - Status:
implemented
Summary
Section titled “Summary”This RFD adds first-class dependency classes to riot manifests:
[dependencies][dev-dependencies][build-dependencies]
But the important requirement is not just new manifest sections. riot must
also treat those classes as distinct dependency graphs:
- a build graph
- a runtime graph
- a dev graph
Those graphs should be selected by command target, not collapsed into one package graph with labels.
That distinction is required so cases like this are legal:
propaneruntime-depends onstdstddev-depends onpropane
That must not be rejected as a normal build cycle, because std does not need
propane to build its checked-in runtime artifact. It only needs propane
when building tests or other dev-only targets.
Motivation
Section titled “Motivation”There are two concrete pressures on the build model now.
The first is package-provided riot-fix rules.
std wants to own std:no-stdlib, and provider authoring wants shared types
from fixme. But fixme depends on syn, syn depends on
ceibo, and ceibo depends on std. If std models fixme as a
normal dependency, the normal package graph gets a false cycle:
std -> fixme -> syn -> ceibo -> std
That dependency is real for build-time tooling, but not for the runtime std
library artifact.
The second is test tooling.
propane is a real package dependency for tests, and std should be able to
use it in its own tests. At the same time, propane quite reasonably has a
normal dependency on std.
So we need all of the following to be expressible at once:
- runtime dependencies needed by checked-in package artifacts
- dev dependencies needed by tests/examples/bench/dev-only outputs
- build dependencies needed only by generators, providers, macros, and other tooling
Without separate dependency graphs, the package model remains too coarse and produces false cycles.
Guide-level explanation
Section titled “Guide-level explanation”Packages can declare three dependency sections:
[dependencies]std = { path = "../std" }
[dev-dependencies]propane = { path = "../propane" }
[build-dependencies]fixme = { path = "../fixme" }The intended meaning is:
build-dependencies: required only for build-time tools, codegen, fused providers, and futurebuild.mlhooksdependencies: required for runtime package artifactsdev-dependencies: required only for tests, examples, benchmarks, and other dev-only package outputs
The critical semantic rule is:
- every package has three build phases:
pkg.buildpkg.runtimepkg.dev
pkg.runtimeimplicitly depends onpkg.buildpkg.devimplicitly depends onpkg.runtimepkg.devshould reuse the package’s runtime artifact instead of rebuilding the package library in a different interface universeriot build,riot install, andriot runtarget theRuntimephaseriot testandriot benchtarget theDevphase- generated tooling such as fused
riot-fixuses theBuildphase when resolving package-owned tooling code
That means this should be valid:
propane -(runtime)-> stdstd -(dev)-> propanebecause there is no runtime cycle, only a dev-only edge back into propane.
Reference-level explanation
Section titled “Reference-level explanation”1. Manifest shape
Section titled “1. Manifest shape”Package manifests support:
[dependencies]foo = { path = "../foo" }
[dev-dependencies]bar = { path = "../bar" }
[build-dependencies]baz = { path = "../baz" }Workspace manifests support the same three sections. A package dependency entry
with { workspace = true } resolves against the matching workspace section:
- package
[dependencies]resolves against workspace[dependencies] - package
[dev-dependencies]resolves against workspace[dev-dependencies] - package
[build-dependencies]resolves against workspace[build-dependencies]
2. Package model
Section titled “2. Package model”riot-model should represent all three dependency classes explicitly:
dependenciesdev_dependenciesbuild_dependencies
It should also expose helpers for the three projections:
runtime_dependenciesdev_dependencies_for_target = dependencies @ dev_dependenciesbuild_tool_dependencies = build_dependencies
and, when needed for metadata traversal:
all_dependencies = dependencies @ dev_dependencies @ build_dependencies
The important part is that these are no longer treated as one implicit graph with three labels attached. The planner must choose a scope explicitly.
3. Package phases
Section titled “3. Package phases”The planner should model three package phases:
BuildRuntimeDev
Each phase has distinct semantics:
pkg.build- represents build-time preparation for the package
- should consume
build_dependencieswhen the selected target graph isBuild - does not implicitly depend on
pkg.runtime
pkg.runtime- consumes
dependencies - implicitly depends on
pkg.build
- consumes
pkg.dev- consumes
dev_dependencies - implicitly depends on
pkg.runtime - should reuse
pkg.runtimeartifacts for the package library instead of recompiling that library as part ofpkg.dev
- consumes
This gives every package a phase chain:
pkg.build -> pkg.runtime -> pkg.devThe important consequence is that pkg.build happens first, even when the
user only asks for a runtime or dev target.
pkg.build should be treated as a build-tooling phase, not yet as a fully
separate package source surface. In particular:
pkg.buildis where build-only dependencies become availablepkg.runtimemust wait forpkg.buildpkg.buildshould not yet produce a normal library artifact that other package phases try to link againstpkg.devshould add dev-only sources and targets on top ofpkg.runtimerather than rebuilding the runtime package library
That distinction should matter for planning: the runtime phase of a package should depend on the completion of its own build phase, but should not treat that build phase as a normal library dependency in its depset.
4. Graph semantics
Section titled “4. Graph semantics”The package graph is no longer a single node per package.
Instead:
- build planning selects
pkg.build - runtime planning selects
pkg.runtime - dev planning selects
pkg.dev
Dependency edges are phase-aware:
pkg.build -> dep.runtimefor build-only relationships when the selected target graph isBuildpkg.runtime -> dep.runtimefor normal package dependenciespkg.dev -> dep.runtimefor dev-only dependencies- implicit phase edges:
pkg.runtime -> pkg.buildpkg.dev -> pkg.runtime
The important implementation detail should be that the implicit self-edge
pkg.runtime -> pkg.build is an ordering edge, not a normal library
dependency. It guarantees build tools run first, but the runtime depset should
not try to resolve .cmxa/library artifacts out of its own build phase.
Likewise, when the selected target graph is Build, build dependencies should
resolve to dependency runtime artifacts, not dependency build phases. That
matches the real use cases we are solving:
std.buildneeds the compiled runtime artifact offixme- fused tooling and future build hooks need access to package code built for use as tools
- they should not yet consume a separate
dep.buildartifact surface
In the Runtime and Dev graphs, pkg.build should still exist as an
ordering prerequisite for the local package, but those graphs should not
recursively traverse build_dependencies. That keeps:
pkg.build -> pkg.runtimeordering intact- build-time tooling out of normal runtime and dev package products
- false cycles like
std.runtime -> std.build -> fixme.runtime -> ...out of the runtime graph
Cycle detection must happen inside the selected graph, not across all declared dependency classes at once.
That is what makes the std / propane case work correctly:
propane.runtime -> std.runtimestd.dev -> propane.runtimestd.runtime -> std.buildstd.dev -> std.runtime- no false cycle between
std.runtimeandpropane.runtime
In other words: a dev-target build may legitimately depend on a package whose runtime artifact depends back on the current package. What matters is whether the specific target graph being built is valid, not whether the union of all dependency classes is acyclic.
5. Command mapping
Section titled “5. Command mapping”The command-to-phase mapping should be:
riot build->Runtimeriot install->Runtimeriot run <binary>->Runtimeriot test->Devriot bench->Dev- generated tool flows such as fused
riot-fix->Build
This is enough to unlock the main real-world cases without requiring a richer target system up front.
6. Execution model
Section titled “6. Execution model”The executor should schedule scoped package nodes, not plain package names.
That means the queue works over nodes like:
kernel.buildkernel.runtimestd.buildstd.runtimestd.dev
rather than over plain package names.
The scheduling model should be:
- construct the scoped package graph for the requested command scope
- enqueue all reachable scoped nodes
- repeatedly pick any node whose dependencies are already completed
- if a node is not ready yet, postpone it and retry later
- mark completion by scoped package key, not by plain package name
This preserves the existing “try work, postpone blocked nodes, retry after more completions” behavior, but makes it phase-aware.
6.1 Runtime build example
Section titled “6.1 Runtime build example”For:
syn -(runtime)-> ceibo -(runtime)-> std -(runtime)-> { kernel, actors }a riot build syn graph should look conceptually like:
kernel.build -> kernel.runtimeactors.build -> actors.runtime -> kernel.runtimestd.build -> std.runtime -> { kernel.runtime, actors.runtime }ceibo.build -> ceibo.runtime -> std.runtimesyn.build -> syn.runtime -> ceibo.runtimeThe important thing is that each runtime node depends on:
- its own build phase
- the runtime phases of its declared dependencies
So the executor is free to start any *.build node that has no unsatisfied
ordering constraints in the selected graph, and runtime nodes unlock naturally
as those finish.
6.2 Dev build example
Section titled “6.2 Dev build example”For:
propane -(runtime)-> stdstd -(dev)-> propanea riot test std:... graph should look conceptually like:
std.build -> std.runtime -> std.devpropane.build -> propane.runtime
propane.runtime -> std.runtimestd.dev -> propane.runtimeThis is the key case the old single-node package graph could not represent.
There is no runtime cycle:
propane.runtime -> std.runtime
and there is no invalid build cycle:
std.dev -> propane.runtime
because std.dev is a distinct node from std.runtime.
6.3 Ordering vs artifact dependencies
Section titled “6.3 Ordering vs artifact dependencies”One subtle but important rule is that not every edge means “treat this as a normal library dependency.”
Specifically:
pkg.runtime -> pkg.buildis an ordering edgepkg.dev -> pkg.runtimeis both an ordering edge and a target relationship
The executor should wait for those dependencies to complete, but the planner
should not automatically treat pkg.build as a library artifact that
pkg.runtime links against.
In practice this means:
- self build-phase edges gate scheduling
- runtime depsets still contain only real package dependency artifacts
- build phases can stay mostly invisible to end users even though they are first-class scheduling nodes
This distinction is what keeps the scoped graph honest without accidentally inventing fake “build artifact libraries” for every package.
7. Workspace loading vs graph construction
Section titled “7. Workspace loading vs graph construction”Workspace loading should still be able to discover packages referenced through any dependency class so tooling can see the full package set.
That means:
- package discovery may traverse all declared dependency classes
- graph construction must remain scope-specific
This cleanly separates:
- “what packages exist in the workspace universe?”
- from
- “what packages are needed for this specific build target?”
8. Immediate use cases
Section titled “8. Immediate use cases”This model is required for both of these:
8.1 std build-only rule authoring
Section titled “8.1 std build-only rule authoring”[build-dependencies]fixme = { path = "../fixme" }This puts fixme on std.build, not on std.runtime, assuming the
provider implementation itself also lives outside src/ in a build-only
location like fix/.
8.2 std test-time dependency on propane
Section titled “8.2 std test-time dependency on propane”[dev-dependencies]propane = { path = "../propane" }This lets std use propane in tests without pretending propane is part of
the std runtime artifact graph.
9. Design constraints
Section titled “9. Design constraints”The implementation should preserve these constraints:
- runtime commands should not pull build-only dependency closures into the runtime graph
- dev commands should not rebuild the package runtime library under a different dependency universe
- build-only tooling flows should resolve against
pkg.build - cycle detection should run against the selected scoped graph, not the union of all dependency classes
Drawbacks
Section titled “Drawbacks”- planner and executor code must choose scopes explicitly instead of assuming one graph
- some commands will eventually need sharper target semantics than “runtime” vs “dev”
- debugging dependency issues becomes slightly more subtle because users need to know which graph a command is using
These are acceptable costs because the alternative is a package model that cannot represent real Riot workflows cleanly.
Rationale and alternatives
Section titled “Rationale and alternatives”Why not keep one package graph and just mark dependency classes on edges
Section titled “Why not keep one package graph and just mark dependency classes on edges”Because that still encourages cycle detection and planning against the union of all edges, and it still leaves one package node trying to represent three different build products.
The requirement is not just richer metadata. The requirement is distinct phase selection by build target.
The bootstrap work that motivated this design also exposes a second issue with
the single-node model: ordering-only relationships and artifact dependencies
get conflated too easily. Representing pkg.build, pkg.runtime, and
pkg.dev as distinct nodes makes it much clearer which edges are:
- pure scheduling constraints
- library/runtime artifact dependencies
- dev-only target relationships
Why not make build-dependencies a riot-fix-specific escape hatch
Section titled “Why not make build-dependencies a riot-fix-specific escape hatch”Because the pressure is broader than riot-fix. Macros, codegen, generators,
future package commands, and other build-time systems want the same separation.
Why not exclude dev-dependencies from all graphs
Section titled “Why not exclude dev-dependencies from all graphs”Because then they are not useful for test/example/bench targets, which defeats the purpose of declaring them in the first place.
Prior art
Section titled “Prior art”The split mirrors the familiar model used by other build tools:
- runtime dependencies
- development/test dependencies
- build-time/tooling dependencies
What is slightly stricter here is the explicit statement that these must map to different dependency graphs, not just different TOML sections.
Unresolved questions
Section titled “Unresolved questions”- Should package commands eventually declare which package phase they consume?
- Should
riot runever gain a way to opt into dev scope for explicitly dev-only binaries? - How far should Riot go before it needs a richer target-specific planning
model beyond
Build,Runtime, andDev?