Skip to content

From YAML to a plan

You wrote customer_etl.yaml: a few nodes and some CXL. But YAML is just text. In reading a plan with --explain, --explain printed an execution plan instead. Something turned your text into that. This lesson follows that transformation, shallowly. The deep version is Planning & Expressions. You’ll build a miniature of the transformation in code so you can predict its shape rather than memorize it.

  • Name the steps that turn YAML into a runnable plan, in order.
  • Trace a single value from source text to the plan, and say what Spanned adds along the way.
  • Predict which stage rejects each kind of mistake: a cycle, an unknown column, a nonsense expression.
  • Explain why Clinker compiles to a CompiledPlan before it runs, in terms of what the runtime is handed.

New terms in this lesson (each is also expanded inline at the point you first need it):

  • node: one step in the pipeline graph; a type: in your nodes: list.
  • DAG: a directed acyclic graph: nodes wired by edges, with no path that loops back on itself. The plan’s nodes form one.
  • validation: the stage that checks the config is well-formed, before any data moves: no cycles, every input exists, paths are allowed.
  • Spanned: a wrapper that pairs a parsed value with where it came from in the source (line & column), so an error can point at the exact spot.

Before a single record moves, your YAML goes through a pipeline of its own:

YAML text
│ parse (span-aware — every value remembers its line & column)
config (typed nodes, still just "what you asked for")
│ validate (no cycles? all inputs exist? paths allowed?)
│ typecheck the CXL (does `lifetime_value.to_int()` make sense?)
│ lower to a graph
CompiledPlan ──► handed to the runtime

Each arrow is a stage that can reject your pipeline, and each rejects a different class of mistake:

  • parse catches malformed YAML: a bad indent, an unterminated string.
  • validate catches structural mistakes: a cycle in the graph, a node that reads an input no other node produces, a path you’re not allowed to write.
  • typecheck catches nonsense expressions, like to_int() on something that can never be an integer.
  • lower turns the validated config into the execution graph the runtime understands.

The parse step is span-aware: each parsed value carries its source location, so a mistake can be reported with a precise line and column rather than a vague “something’s wrong.” That location-carrying wrapper is Spanned:

clinker-plan ·yaml.rs ·Spanned type @19acdcb4
pub struct Spanned<T> {
pub value: T,
pub referenced: Location, // where this value is used in the source
pub defined: Location, // where it was defined (e.g. a YAML anchor)
}

Everything upstream converges on one typed artifact, the CompiledPlan. It bundles the lowered graph, the validated config, and the typechecked CXL:

clinker-plan ·compiled.rs ·CompiledPlan type @19acdcb4
pub struct CompiledPlan {
dag: ExecutionPlanDag, // the lowered execution graph
config: PipelineConfig, // the validated configuration
// …plus the validated compile outputs the runtime reads (typechecked CXL, bound schemas)
// ...
}

When you ran --explain, you saw the engine do all of this (parse, validate, typecheck, lower) and then print the result instead of executing. The execution plan you read was this CompiledPlan, rendered as text. Its dag field holds the nodes you counted in that output (DAG nodes: 4), now wired into a graph with no cycles: a DAG.

Two reasons, both of which you can already feel:

  1. Errors surface before any data moves. If a column name is misspelled or a CXL expression is nonsense, you find out at plan time, not halfway through a million-row file with a half-written output.
  2. The runtime gets a proof, not a wish. The executor never receives raw YAML. It only ever accepts a CompiledPlan, an artifact that, by existing, proves the pipeline is well-formed. The boundary between “planning” and “running” is one of Clinker’s defining design decisions; we open it up in Planning & Expressions.

On the Rust side, this whole stage runs on Result: parsing, validation, and typechecking each return success or a typed error rather than throwing. Errors are values you handle, and they roll up into one error vocabulary (PipelineError, covered in the error-handling lesson).

You’ll understand the transformation best by building a small version of it. Each rung below is a self-contained, runnable Rust program. We stage one new idea at a time: first a span that travels with a value, then the stages as fallible steps, then the whole compile as a function you write.

Worked: a value that remembers where it came from

Section titled “Worked: a value that remembers where it came from”

The first thing parse does is wrap every value in its source location, so a later stage can blame the right line. Here is Spanned in miniature: a value paired with a Location, and a parse step that attaches one. Run it and watch the location ride along.

rust // editable

The takeaway: the span is not metadata stored off to one side. It is glued to the value by the type. Every stage downstream still has it, so a validation error three steps later can still point at line 7.

Now the validation stage. Below, a config is a list of nodes, each naming the input it reads. Validation must reject a node whose input no other node produces, the exact mistake from the Predict-first opener. Two checks are written; complete the third arm that flags an unknown input.

rust // editable
💡 Hint 1
You’re inside the if !produced branch, so you already know the input is missing. Return an Err(String) naming both node.name and input. That’s the precise message a span would let the real engine attach to a line.
Show solution
return Err(format!(
"node {:?} reads {:?}, but no node produces it",
node.name, input,
));

This is validation in one line of judgment: walk the declared graph, and the moment a node reads something nothing produces, refuse before a record exists. The real engine reports the same class of error, plus the line number that the Spanned wrapper carried this far.

Your turn with the stages wired together. Write compile, which runs the journey from the diagram: parse → validate → lower, returning Result<CompiledPlan, String>. Each helper already returns a Result; chain them so that any stage failing aborts the compile with its error, and only an all-green run produces a plan. (The ? operator does exactly this early-return-on-Err; if it’s new, the match form works too.)

rust // editable
Show solution
fn compile(src: &str) -> Result<CompiledPlan, String> {
let nodes = parse(src)?; // parse fails -> return its Err here
validate(&nodes)?; // validate fails -> return its Err here
Ok(lower(&nodes)) // both passed -> lower can't fail; build the plan
}

That’s the engine’s spine in three lines: each stage is a fallible Result-returning step, ? short-circuits on the first failure, and a CompiledPlan is produced only when every stage succeeded. The real pipeline adds a typecheck stage and far richer types, but the shape (sequence fallible stages, emit a proof at the end) is exactly this.

Read the cited CompiledPlan again with the ladder behind you. Notice what its existence guarantees: the runtime’s entry point takes a CompiledPlan, not a string of YAML. So a caller cannot hand the executor an unvalidated pipeline. There is no constructor for CompiledPlan that skips the stages. The type is the proof.

What a junior might misread: thinking --explain is “just a linter” that scans syntax. It runs the entire compile (parse, validate, typecheck, lower), and the plan it prints is the genuine CompiledPlan the runtime would consume. The only thing it skips is execution.

Why-bridge: the plan is a boundary, not a formality

Section titled “Why-bridge: the plan is a boundary, not a formality”

Why does the engine bother producing a separate, typed CompiledPlan at all, instead of interpreting the YAML as it goes? Because the plan is a boundary: everything that can be checked statically is checked once, up front, and the result is an artifact that carries that guarantee forward. The runtime never re-validates, never re-parses; it trusts the CompiledPlan because nothing else can construct one.

That is why a broken column reference fails at --explain time and not at row one million: the failure happens in the stage that can see the whole graph, before the boundary, while there’s still nothing to clean up.

You can now name the stages that turn YAML into a plan, trace a value through them with its span, predict which stage rejects which mistake, and build a miniature compile that emits a plan only when every stage passes. Next: how a record actually travels through the graph that plan describes.

Go deeper on the Rust (optional, one-directional; the same Result/? mechanics taught from first principles in The Rust Book):

Glossary terms used: node, Result, record.