The plan/runtime boundary
The executor is the engine’s hot, parallel, memory-bounded core, the part you least want
running a half-checked plan. The last three lessons built up the validated pieces: a screened
path, a strict located parse. This lesson is where they converge into a single typed handle,
CompiledPlan, and where the engine makes “this plan is fully compiled” the same kind of
unforgeable, compiler-checked fact that ValidatedPath made of “this path is screened.”
What you’ll be able to do
Section titled “What you’ll be able to do”- Name what
CompiledPlanfreezes in (the lowered DAG, the typechecked CXL, the bound schemas, the source hash) and say why each is settled once. - Distinguish plan-time (static, done once) work from run-time (dynamic, per record) work, and give two examples of each.
- Explain why
run_plan_with_readers_writersaccepts&CompiledPlanand refuses a raw&PipelineConfig, in terms of the only constructor. - Predict the compiler error you get when you hand the executor an unvalidated plan, and trace why the type is the proof that compilation happened.
New terms in this lesson (each is also expanded inline at the point you first need it):
- plan (
CompiledPlan): the compiled, validated form of a job; here, the typed handle the runtime consumes. - runtime / executor: the per-record engine that runs a plan; it accepts only the handle.
- compile-time validation: a guarantee enforced by the type system, so a violation fails to build, not at run time.
- the boundary: the membrane
CompiledPlandraws between decide once and run many.
Predict first
Section titled “Predict first”A handle that means “compiled”
Section titled “A handle that means “compiled””CompiledPlan is the output of compilation, and like ValidatedPath, its fields are
private, so the only way to hold one is to have gone through the compiler:
clinker-plan ·compiled.rs ·CompiledPlan type @19acdcb4
/// CompiledPlan is the typed-handle output of PipelineConfig::compile.#[derive(Debug)]pub struct CompiledPlan { dag: ExecutionPlanDag, // the lowered execution DAG config: PipelineConfig, // the validated config // …plus the validated compile outputs the runtime reads (typechecked CXL, bound schemas) channel_identity: Option<ChannelIdentity>, pipeline_hash: [u8; 32], // BLAKE3 of the source YAML}Everything expensive and checkable has already happened by the time this struct exists. The
DAG is lowered (nodes resolved, topology fixed). Every CXL expression is typechecked,
its typed program frozen into the plan. Schemas are bound. The source is hashed. The
struct is read-only from the outside: accessors like dag() and config() hand out shared
references, nothing more.
The static/runtime split: decide once, run many
Section titled “The static/runtime split: decide once, run many”This is the architectural heart of the lesson. Work in a streaming engine falls into two buckets:
- Plan-time (static), done once per job: parse, validate, resolve references, lower the DAG, typecheck every CXL expression, bind schemas. Expensive, but paid a single time.
- Run-time (dynamic), done once per record: pull a record, evaluate the already-typechecked expressions, route it, write it. This runs millions of times.
CompiledPlan is the membrane between them. Anything that can be settled before the first
record flows is settled at compile time and frozen into the handle, so the per-record path
never repeats it. Each transform’s Arc<TypedProgram> is frozen into the plan, and the executor
reads it directly instead of re-typechecking. The executor never typechecks a CXL expression at
run time. That was done once, at compile time, and the
result is in the handle. (CXL: a staged language shows the
same “compile once, run per record” idea inside CXL itself.)
The nodes the DAG is built from are themselves self-describing: each PlanNode owns its
topology, its display form, and its execution payload, plus a span back to the source YAML for
diagnostics:
clinker-plan ·mod.rs ·PlanNode type @19acdcb4
pub enum PlanNode { Source { name: String, /* ... */ }, Transform { name: String, /* ... */ }, Output { name: String, /* ... */ }, // ... each variant owns its topology, display, and execution payload}The executor accepts the handle and nothing else
Section titled “The executor accepts the handle and nothing else”Here’s the enforcement. The executor’s public entry point takes &CompiledPlan. It does
not accept a PipelineConfig. And a compile_fail doctest pins that refusal so it can
never silently regress:
clinker-exec ·mod.rs ·run_plan_with_readers_writers fn @19acdcb4
/// Accepts the typed `CompiledPlan` handle returned by `PipelineConfig::compile`.////// ```compile_fail/// let _ = PipelineExecutor::run_plan_with_readers_writers(/// cfg, // ← should be &CompiledPlan, not &PipelineConfig/// // .../// );/// ```pub fn run_plan_with_readers_writers<W: Into<WriterRegistry>>( plan: &CompiledPlan, // ...) { /* ... */ }Trace the guarantee, exactly as with ValidatedPath: to run, you need a &CompiledPlan; to
get a CompiledPlan, you must call PipelineConfig::compile (the only constructor); and
compile is where all validation lives. So “the executor ran an unvalidated plan” is not a bug
you guard against at run time. It’s a program that does not compile. The type is the
proof that compilation happened.
Build the boundary yourself
Section titled “Build the boundary yourself”The whole pattern fits on one screen, and you’ll build it in three rungs: first read a fully worked miniature, then complete the decide-once step, then write the boundary from a skeleton. Each rung is a self-contained, runnable Rust program. One new idea per rung.
Worked: the boundary, end to end
Section titled “Worked: the boundary, end to end”Here is the pattern complete and annotated: a raw plan, a compile step that validates and
bakes a result, a private-fielded handle, and a runner that accepts only the handle. Run it,
then read the commented-out block to see the refusal.
> output appears here — press Run
Uncomment the last block and the build fails: run wants a &CompiledPlan, you have a
RawPlan, and you cannot hand-build a CompiledPlan because its fields are private. The only
way forward is through compile, which is the only place validation happens. That refusal is
the plan/runtime boundary in one compiler error.
Completion: decide once at the boundary
Section titled “Completion: decide once at the boundary”The new idea this rung isolates is the decide-once property, the whole point of the
boundary. Below, compile validates, but it does not yet pre-compute the expensive thing
that the per-record runner would otherwise redo on every record. The runner here loops over
records and, for each one, needs the uppercased step names. Computing those uppercases is
plan-time work: it depends only on the plan, not on the record, so it must be settled once
inside compile and frozen into the handle, never recomputed in the hot loop. Fill the gap so
the handle carries the pre-computed names.
> output appears here — press Run
💡 Hint 1
raw.steps: Vec<String>. Map each step to its uppercase form and collect: raw.steps.iter().map(|s| s.to_uppercase()).collect(). The point is where it runs: once in compile, not once per record in run.Show solution
let upper_steps: Vec<String> = raw.steps.iter().map(|s| s.to_uppercase()).collect();This is the static/run-time split in one line: the uppercasing depends only on the plan, so it
happens once in compile and is frozen into the handle. The per-record run loop only
reads upper_steps. The real engine does exactly this at scale: it typechecks every CXL
expression once at compile time and freezes each Arc<TypedProgram> into the plan, so the
runtime pulls the typed program instead of re-typechecking on every record.
Faded: enforce the boundary from scratch
Section titled “Faded: enforce the boundary from scratch”Your turn with much less scaffolding. Write the whole boundary: a RawPlan, a CompiledPlan
with a private field, an accessor, the single compile constructor that rejects an empty
plan, and a run that accepts only &CompiledPlan. The test of success: run(&raw) must be a
compile error, and the only path to a CompiledPlan must be through compile.
> output appears here — press Run
Show solution
mod plan { pub struct RawPlan { pub steps: Vec<String> }
pub struct CompiledPlan { steps: Vec<String>, // private — no outside construction }
impl CompiledPlan { pub fn steps(&self) -> &[String] { &self.steps } }
pub fn compile(raw: RawPlan) -> Result<CompiledPlan, String> { if raw.steps.is_empty() { return Err("a plan needs at least one step".into()); } Ok(CompiledPlan { steps: raw.steps }) }}
fn run(plan: &plan::CompiledPlan) { println!("running {:?}", plan.steps());}The private steps field is the whole enforcement: outside the plan module, no one can write
CompiledPlan { steps: ... }, so compile is the only door in. Demand &CompiledPlan at the
runner and “run an unvalidated plan” becomes unrepresentable: exactly the move ValidatedPath
made for screened paths two lessons back, now made for whole plans.
// quick check
Why does PipelineExecutor accept &CompiledPlan rather than &PipelineConfig?
The typed handle is proof of compilation. Its private fields mean it can only come from PipelineConfig::compile; demanding it at the executor boundary makes running an unvalidated plan impossible to express, enforced by a compile_fail doctest.
Retrieval checkpoint
Section titled “Retrieval checkpoint”Connections
Section titled “Connections”You can now name what CompiledPlan freezes in, sort plan-time work from run-time work,
explain why the executor accepts only the typed handle, and predict the compile error that
makes “run an unvalidated plan” unrepresentable. You’ve seen the engine concentrate all its “is
this valid?” work behind one typed handle. But plenty can still go wrong at run time: a bad
cast, a file error, an internal invariant tripped. How the engine classifies those failures,
and which ones abort versus get quarantined, is the next lesson.
Go deeper on the Rust (optional; compile returns a Result, and the
?-sequenced fallible stages behind the boundary are the same mechanics taught from first
principles in The Rust Book):
Glossary terms used: plan, runtime / executor, compile-time validation, Result.