From Scripts to Contracts
What Rust's type system taught me about infrastructure composition — RustLab 2026, Bologna
Show me the code.
- This talk contains code. A fair amount.
- Code is where a claim becomes real: it compiles, it runs, or it fails. The rest, any intelligence can infer.
- Reading it is optional. Understanding it is optional. It is there so nothing I say floats.


#[derive(Debug)]
enum Lang { COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust }
use Lang::*;
struct Engineer { since: u16, journey: &'static [Lang], builds: &'static [&'static str], beliefs: &'static str }
const ME: Engineer = Engineer {
since: 1987,
journey: &[COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust],
builds: &["provisioning", "ontoref", "rustelo"],
beliefs: "Taoism — yang is form, yin is flow; neither wins",
};
// Checked by rustc, not at run time: reorder `journey` and this does not compile.
const _: () = assert!(matches!(ME.journey.last(), Some(Rust)), "finally");
fn main() {
println!("experience: {} years — software development and systems integrations", 2026 - ME.since);
println!("journey: {:?}", ME.journey);
println!("builds: {}", ME.builds.join(" · "));
println!("community: ULPGC + SPEGC workshops · co-organizer, Rust Las Palmas");
println!("beliefs: {}", ME.beliefs);
println!("prequel: Rustikon 2026 — \"Why I Needed Rust, Finally\"");
}

#[derive(Debug)]
enum Lang { COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust }
use Lang::*;
struct Engineer { since: u16, journey: &'static [Lang], builds: &'static [&'static str], beliefs: &'static str }
const ME: Engineer = Engineer {
since: 1987,
journey: &[COBOL, Informix4GL, SQL, C, Perl, PHP, Ruby, Bash, JavaScript, Nushell, Go, KCL, Nickel, Rust],
builds: &["provisioning", "ontoref", "rustelo"],
beliefs: "Taoism — yang is form, yin is flow; neither wins",
};
// Checked by rustc, not at run time: reorder `journey` and this does not compile.
const _: () = assert!(matches!(ME.journey.last(), Some(Rust)), "finally");
fn main() {
println!("experience: {} years — software development and systems integrations", 2026 - ME.since);
println!("journey: {:?}", ME.journey);
println!("builds: {}", ME.builds.join(" · "));
println!("community: ULPGC + SPEGC workshops · co-organizer, Rust Las Palmas");
println!("beliefs: {}", ME.beliefs);
println!("prequel: Rustikon 2026 — \"Why I Needed Rust, Finally\"");
}You have seen this log
$ ./deploy.sh production [ok] network applied [ok] secrets propagated [ok] orchestrator updated [ok] monitoring reloaded [ok] deploy complete exit 0
- Every step reported success.
- The order between them was wrong — and nothing knew there was an order.
- The pipeline was green. Production was not.
Three layers I kept confusing
| Layer | Question | Who checks it |
|---|---|---|
| Shape | Does this field exist, with the right primitive? | serde, JSON Schema |
| Form | Do these fields agree with each other? | contracts, validated constructors |
| Composition | Is the whole graph of steps legal? | … usually nobody |
- Rust made me fluent in the first two: newtypes, enums,
TryFrom. - The third layer is where every 3 AM alert I remember came from.
- This talk is what happened when I made layer three a contract.
The stack, honestly
Nickel
form — config time
Component contracts, formula DAGs, workspace composition. Rejects before anything runs.
Nushell
orchestration
The CLI, the live DAG executor today, provider glue. Structured data, not strings.
Rust
the platform
Orchestrator, provider traits, NATS services, control center, post-quantum vault.
A component is a contract
# schemas/lib/contracts.ncl (excerpt)
ComponentDef = {
name | String,
mode | [| 'taskserv, 'cluster, 'container |] | default = 'taskserv,
depends_on | Array {
name | String,
kind | [| 'Requires, 'PrefersBefore, 'ConflictsWith |] | default = 'Requires,
} | default = [],
on_error | [| 'Stop, 'Continue, 'Retry |] | default = 'Stop,
requires | {
storage | { size | String, persistent | Bool } | optional,
ports | Array { port | Number, exposure | [| 'public, 'private, 'internal |] },
} | default = {},
provides | {
service | String | optional,
port | Number | optional,
endpoints | Array String | default = [],
} | default = {},
# mandatory — never omit
concerns | ServiceConcerns,
..
}Concerns: typing what nobody types
# schemas/lib/concerns.ncl
ConcernState = { kind | [| 'enabled, 'disabled, 'pending, 'inherited |], .. }
ServiceConcerns = {
tls | ConcernState, dns | ConcernState, certs | ConcernState,
backup | ConcernState, observability | ConcernState, security | ConcernState,
}
# a component that is honest about its gaps
concerns = {
tls = c.disabled "stateless service, no TLS termination",
backup = c.pending "policy to be defined" "BACKUP-001",
..
}- You cannot omit a concern. You can only say why it’s off — or which ticket closes it.
- "Which components have
backup = 'pending?" becomes a query, not an audit.
The graph is a value — and it is checked
# schemas/lib/formula.ncl (condensed) — runs at config time
let _Formula = std.contract.custom (fun label value =>
let node_ids = value.nodes |> std.array.map (fun n => n.id) in
let bad_deps = value.nodes |> std.array.flat_map (fun node =>
node.depends_on
|> std.array.filter (fun dep => !(std.array.elem dep.node_id node_ids))
|> std.array.map (fun dep => "node '%{node.id}' depends_on unknown '%{dep.node_id}'")
) in
if bad_deps != [] then
std.contract.blame_with_message "invalid depends_on: %{std.string.join ", " bad_deps}" label
else 'Ok value
)- Duplicate node ids, dangling
depends_on, bad edge endpoints → rejected. - A formula = DAG of components on one server.
max_parallel,on_errorare policy on the graph.
Graphs of graphs: the composition
# workspaces/libre-wuji/infra/libre-wuji/dag.ncl
composition.formulas = [
{ formula_id = "libre-wuji-cp-formula",
depends_on = [],
health_gate = {
check_cmd = "kubectl get nodes … | grep -v NotReady",
retries = 18,
} },
{ formula_id = "libre-wuji-wrk-0-formula",
depends_on = [{ formula_id = "libre-wuji-cp-formula",
condition = 'Healthy }],
parallel = true },
# wrk-1, wrk-2, strg-* …
]Exactly one of — the lesson I hadn’t applied
1 // what Rust wanted from the start: two states, not four
2 pub enum NodeKind {
3 TaskServ { taskserv: TaskServDef },
4 Component { component: ComponentDef },
5 }
6
7 pub struct FormulaNode {
8 pub id: String,
9 pub kind: NodeKind,
10 }
11
12 match &node.kind {
13 NodeKind::TaskServ { taskserv } => run_taskserv(taskserv),
14 NodeKind::Component { component } => run_component(component),
15 }
bail! arms disappear because the state can't be built.Providers satisfy a contract — the core doesn't move
// platform/crates/provisioning-compute/src/trait_def.rs
#[async_trait]
pub trait ComputeProvider: Send + Sync + Debug + 'static {
fn kind(&self) -> ProviderKind;
async fn spawn(&self, spec: InstanceSpec) -> Result<Instance, ComputeError>;
/// MUST be idempotent: destroying an unknown handle returns Ok(()).
/// MUST NOT return NotFound — that case maps to Ok(()).
async fn destroy(&self, handle: &InstanceHandle) -> Result<(), ComputeError>;
async fn status(&self, handle: &InstanceHandle) -> Result<InstanceStatus, ComputeError>;
async fn list(&self, label_selector: &str) -> Result<Vec<Instance>, ComputeError>;
async fn health(&self) -> Result<ProviderHealth, ComputeError>;
}- Adding a provider = implementing a trait + a Nickel schema. Not editing a core — even your own local infra, or any Nushell script behind a JSON envelope.
#[non_exhaustive] enum ComputeError— failure modes are part of the contract.- But read the doc comments: "MUST be idempotent" is an invariant the type can’t hold. Only tests can — so every provider, yours included, runs the same conformance suite:
run_suite(provider).
Live: break the contract
let f = import "schemas/lib/formula.ncl" in
let Acyclic = import "acyclic.ncl" in
f.make_formula {
id = "rustlab-live",
description = "control plane, typed",
provider = "hetzner",
server = "libre-wuji-cp-0",
nodes = [
f.make_node { id = "os", taskserv = { name = "os" } },
f.make_node { id = "runc", taskserv = { name = "runc" }, depends_on = [{ node_id = "os" }] },
f.make_node { id = "crio", taskserv = { name = "crio" }, depends_on = [{ node_id = "runc" }] },
f.make_node { id = "kubernetes", taskserv = { name = "kubernetes" }, depends_on = [{ node_id = "crio" }] },
],
}Live: break the contract
let f = import "schemas/lib/formula.ncl" in
let Acyclic = import "acyclic.ncl" in
f.make_formula {
id = "rustlab-live",
description = "control plane, typed",
provider = "hetzner",
server = "libre-wuji-cp-0",
nodes = [
f.make_node { id = "os", taskserv = { name = "os" } },
f.make_node { id = "runc", taskserv = { name = "runc" }, depends_on = [{ node_id = "os" }] },
f.make_node { id = "crio", taskserv = { name = "crio" }, depends_on = [{ node_id = "runc" }] },
f.make_node { id = "kubernetes", taskserv = { name = "kubernetes" }, depends_on = [{ node_id = "crio" }] },
],
}Ownership, mapped — as analogy, not mechanism
| Rust | Infrastructure |
|---|---|
| Single owner | One component owns a resource; others borrow its provides |
& vs &mut | Read endpoints freely; mutating someone else’s resource is a design error |
| Lifetimes | "This credential is valid for this deploy session" |
TryFrom / validated constructors | Contracts that reject at the boundary |
enum over Option × Option | 'pending "reason" "BACKUP-001" instead of a missing field |
| Trait bounds | "Satisfy ComputeProvider and you’re a provider" |
Aliased mutable state is the root of most data races.
Two systems believing they own the same resource is the root of most infra incidents.
Why rollback is not Drop
I wanted this:
{
let plan = Plan::apply(&ctx)?;
plan.verify()?;
plan.commit();
} // dropped uncommitted → rollbackDropis synchronous. Cloud APIs are not.Dropcannot fail. Deleting a volume can.Droporder is lexical. Teardown order is a graph.
What I have:
#[async_trait]
pub trait ProviderRollbackHandler: Send + Sync {
async fn rollback_resource(&self, r: &ResourceSnapshot)
-> Result<()>;
async fn cleanup_resources_after(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
async fn validate_rollback(&self, cp: &Checkpoint)
-> Result<Vec<String>>;
}Explicit, checkpointed, per provider — and validated before it runs.
The bug I found preparing this talk
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct DependencyGraph {
nodes: HashSet<String>,
edges: HashMap<String, Vec<String>>, // task -> its dependencies
reverse_edges: HashMap<String, Vec<String>>, // dependency -> its dependents
}
impl DependencyGraph {
fn add_dependency(&mut self, from: &str, depends_on: &str) {
self.nodes.extend([from.to_string(), depends_on.to_string()]);
self.edges.entry(from.into()).or_default().push(depends_on.into());
self.reverse_edges.entry(depends_on.into()).or_default().push(from.into());
}
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}The bug I found preparing this talk
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Default)]
struct DependencyGraph {
nodes: HashSet<String>,
edges: HashMap<String, Vec<String>>, // task -> its dependencies
reverse_edges: HashMap<String, Vec<String>>, // dependency -> its dependents
}
impl DependencyGraph {
fn add_dependency(&mut self, from: &str, depends_on: &str) {
self.nodes.extend([from.to_string(), depends_on.to_string()]);
self.edges.entry(from.into()).or_default().push(depends_on.into());
self.reverse_edges.entry(depends_on.into()).or_default().push(from.into());
}
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}The bug I found preparing this talk — continued
impl DependencyGraph {
// same logic as orchestrator/src/workflow.rs:174
fn topological_sort(&self) -> Result<Vec<String>, String> {
let mut in_degree: HashMap<&str, usize> =
self.nodes.iter().map(|n| (n.as_str(), 0)).collect();
for deps in self.edges.values() {
for dep in deps {
*in_degree.entry(dep).or_default() += 1;
}
}
let mut queue: VecDeque<&str> =
in_degree.iter().filter(|(_, d)| **d == 0).map(|(n, _)| *n).collect();
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.to_string());
for dependent in self.reverse_edges.get(node).into_iter().flatten() {
let d = in_degree.get_mut(dependent.as_str()).ok_or("unknown node")?;
*d -= 1;
if *d == 0 { queue.push_back(dependent); }
}
}
if result.len() != self.nodes.len() {
return Err("Circular dependency detected in workflow".into());
}
Ok(result)
}
}Types check shape. Tests check meaning.
Result<Vec<String>>is satisfied by the right order, the wrong order, and anErrthat lies.- Graph algorithms need properties, not examples:
proptest! {
#[test]
fn topo_order_respects_every_edge(g in arb_dag(1..64)) {
let order = g.topological_sort().unwrap();
let pos: HashMap<_, _> = order.iter().enumerate().map(|(i, n)| (n, i)).collect();
for (task, deps) in &g.edges {
for dep in deps { prop_assert!(pos[dep] < pos[task]); }
}
}
}The boundary is where types leak
Nickel ComponentDef — 20+ fields
mode | [| 'taskserv, … |] | default = 'taskserv,
requires | { … },
provides | { … },
concerns | ServiceConcerns, # mandatory
.. # open recordRust ComponentDef — 7 fields
#[derive(Deserialize)]
pub struct ComponentDef {
pub name: String,
#[serde(default = "default_deploy_mode")] // Cluster!
pub mode: DeployMode,
// requires, provides, concerns:
// silently dropped by serde
}- Two type systems, one concept, two defaults.
serdeignores unknown fields unless you say#[serde(deny_unknown_fields)].- Fix: one source of truth + a CI test that exports every Nickel fixture and deserializes it strictly.
A contract protects only where it’s applied
provides | {
service | String | optional,
port | Number | optional,
# Added 2026-08-18: the surrealdb catalog component has always declared
# provides.protocol = "http+ws" and this closed record rejected it, so the
# component could be listed in settings.ncl (which does not apply
# ComponentDef) but not used as a DAG component node, which does. The
# incompatibility stayed invisible until a workspace first tried to
# schedule it through the unified model.
protocol | String | optional,Gradual typing is a feature — and a place for bugs to wait. Know where your contracts run.
External state doesn’t typecheck
condition = 'Healthyis a typed enum. What Healthy means iskubectl get nodes | grep -v NotReady.200 OKfrom a control plane often means queued, not done.- Eventual consistency, rate limits, idempotency windows: none of it is in a signature.
- I can validate the plan. I cannot validate the cloud.
Operational, not structural. Observed, not proven.
What I’d do differently
- Sum types end to end. If the config language can’t express them, generate the Rust side — don’t hand-copy
Option × Option. - Typed errors in the composition layer. It still returns
anyhow. The providers already have#[non_exhaustive]enums; the graph deserves one. - One executor. Nushell runs the live DAG; a Rust engine sat beside it with no tests on its sort. Two engines = two semantics.
- Property tests on every graph algorithm. From day one — a
Result<Vec<String>>checks the shape of the answer, not its order. - Check acyclicity where the graph is declared. Not where it’s executed — the cycle passed
dag.ncland only Rust saw it.
Same paradigm, another subject
| Rust · the code | provisioning · infrastructure | ontoref · projects | |
|---|---|---|---|
| Declare | types, enums | ComponentDef, formulas | axioms, tensions, practices — core.ncl |
| State | typestate | op status, health gates | state.ncl: current → desired, with blockers |
| Check before acting | the compiler | contract before deploy | a decision checked against invariants · pre-commit act-gate |
| What counts as done | Result, not exit 0 | the health gate, not "applied" | a step’s verdict is its check’s exit code, not its report |
| Trace | — | signed ops → Radicle ledger | signed work order + receipt |
form · the plan
flow · the cloud
What this changes for AI agents
Helps
- Context is queried, not remembered. The agent asks the project what is true (
ontoref describe … --actor agent) instead of guessing from files. - The agent proposes, the contract disposes. Same validators over MCP as on the CLI; proposals land unsigned on
ops.pending.<ws>, only signed commands reachops.cmd.<ws>. - Its word is not the verdict. A step’s status is its check’s exit code; a contradicting self-report is refused.
- Human acts stay human. Only a person ratifies or accepts; an agent signs only as itself. Its reach is a declared axis:
'AgentBoundary.
Doesn’t solve
- Well-typed and wrong. The
topological_sortcompiled. - Declared ≠ read. An agent cites a mechanism nothing executes — exactly as people do.
- "There is no check" is a claim too. Absence needs the same proof as presence.
- Every contract is adoption cost. More form, more friction.
The same discipline, one level up
# a workspace → its domain (libre-daoshi/.domains-ontoref/…)
{ id = "libre-daoshi-to-provisioning",
from = "libre-daoshi", to = "provisioning",
axis = 'Governance, state = 'Node, resolution = 'Merge }
# a project → gov, one way (provisioning/.domains-ontoref/…)
{ id = "provisioning-to-gov",
from = "provisioning", to = "gov",
axis = 'Dependency, state = 'Linked,
dep_kind = 'Conformist, mutual = false }'Governanceis a DAG.bond validatewalks it and reports a cycle as an error.'Dependencyis a plane. Never walked for cycles: a mutual pair would be legal — by type, not by exception.'Merge: the node wins per id; the rest is inherited from the domain.
Where this talk’s ideas came from
Takeaways
- Type the composition, not only the resources. The edges are where incidents live.
- Absence must be explicit.
'pending "why" "TICKET-42"beats a missing field. - The discipline recurses. Components → formulas → infrastructure → projects and their bonds.
- Seams need their own tests. Config ↔ Rust, plan ↔ cloud, type ↔ meaning.
- Agents propose; contracts, checks and people dispose. Know where each guarantee ends.
And if it typechecks, it still has to be true.
Numbers
| Rust | 28 workspace crates · ~270k LOC |
| Nickel | 984 files · ~71k LOC |
| Nushell | 895 files · ~199k LOC |
| Production workspace | libre-wuji · Hetzner fsn1 · 71 component definitions |
| Compute providers | Hetzner · UpCloud · AWS · DigitalOcean · local |
| Bus | NATS JetStream · JSON payloads · serde structs |
| Audit | ops.audit.* → Radicle signed history |
Why not all of it in Rust?
Nickel
- Config edited and validated without recompiling the platform
- Contracts and merge are the language, not a library you call
- Blame points at the line of config that broke the contract
build.rs — every config change is a rebuild, every contract lives where only Rust readers see itNushell
- Glue as structured data — tables and records, not strings
- A provider can be a script:
ScriptProvider, checked by the same conformance suite - Changed in place, on the machine that runs it
Rust
- Orchestrator, NATS services, control center, post-quantum vault
- No GC, one static binary,
Send + Syncchecked - Failure in the signature:
Result,#[non_exhaustive]
topological_sort bug compiled — tests caught itRust set the bar; it doesn't have to do all the typing.
Why not Terraform / Pulumi / CUE / KCL / Crossplane?
| Terraform OpenTofu | Pulumi | CUE | KCL | Crossplane | provisioning | |
|---|---|---|---|---|---|---|
| Typed resources | ● provider schemas | ● host-language types | ● unification | ● schemas + check | ● CRD / OpenAPI | ● Nickel contracts |
| Composition is a declared value | ◐ implicit from references | ○ built by running code | ◐ data, no executor | ◐ data; KusionStack runs it | ◐ Compositions, eventual order | ● formula + composition DAG |
| Policy on the graph error policy · parallelism · health gates | ○ global -parallelism | ◐ written in code | ○ — | ○ not in the language | ◐ readiness checks | ● on_error · max_parallel · health_gate per node |
| Cross-cutting concerns explicit | ○ tags, convention | ○ convention | ◐ encodable | ◐ encodable | ○ convention | ● 6 typed concerns, 'pending + ticket |
| Illegal graph rejected before anything runs | ● refs + cycles at validate | ◐ preview runs the program | ● what you encode | ● what you encode | ○ found at reconcile | ◐ refs at config time · cycles only in Rust |
| Drift: continuous reconciliation | ◐ plan shows drift | ◐ refresh | ○ — | ○ — | ● the model | ◐ gates + events · no reconcile loop |
| Ecosystem · providers | ● thousands | ● large | ◐ growing | ◐ CNCF sandbox | ● large | ◐ 5 in the catalog · any by contract — a script, your own infra — checked by a conformance suite · one maintainer |
the edges between resources, typed and checked before anything runs.
"You could do this in any language"
Yes — the discipline travels
- The composition is typed in Nickel and run by Nushell. Port the executor to Go and the contracts still hold.
- That is the point of the talk: a Rust habit, exported to the part of the system that is not Rust.
- And Rust did not save me from a wrong
topological_sort. Tests did.
What Rust carries — all in one compiler
Sum types + exhaustive match | illegal states unbuildable — NodeKind, not Option × Option |
Result + #[non_exhaustive] | failure is part of the signature — ComputeError |
Send + Sync + 'static | sharing a provider across tasks checked at compile time |
| No GC · one static binary | daemons that must not stall: ops-controller, ncl-sync, a post-quantum vault |
serde at the boundary | the seam typed both ways — and you see where it leaks |
Federation (ADR-042)
- Projects declare Integration Modes; contracts ship as OCI artifacts (
application/vnd.ontoref.mode.v1) viaoras. - Workspaces consume them as typed imports.
- Cross-project impact is a bounded BFS:
impact_graph(slug, node, max_depth), depth ≤ 5.
Links
Infrastructure
Community
Protocol · the gov and custody domains, part of ontoref
· reads



