Plan a change without breaking a boundary
You can now extend all three of the engine’s seams. The last skill is the one that separates a contributor from a careful contributor: knowing before you write code which architectural boundary your change touches, and recognizing the decisions that aren’t yours to make alone. This is the capstone lesson, and the bridge to the capstones themselves.
What you’ll be able to do
Section titled “What you’ll be able to do”- Name three of the engine’s architectural invariants and say where each is written down.
- Read a crate’s
Cargo.tomland decide whether a proposed dependency respects the one-way layering. - Trace the blast radius of a proposed change (which crates and boundaries it touches) before writing any code.
- Distinguish a change you can land directly from one that must go through a Decision Gate, by checking it against the trigger list.
New terms in this lesson (each is also expanded inline at the point you first need it):
- invariant: a load-bearing rule the rest of the code assumes holds.
- seam: a deliberate, contained extension point you can add to safely.
- blast radius: everything a change actually touches, traced before you start.
- change scoping: mapping the blast radius against the invariants and the gate triggers, first.
Predict first
Section titled “Predict first”The invariants are written down
Section titled “The invariants are written down”Clinker keeps its architectural rules explicit, in docs/ai. They are the load-bearing
properties everything else assumes, not style preferences. An
invariant
quietly broken isn’t a bug in the usual sense; it’s a boundary break the type system might
not catch. A few of the most concrete (quoted):
clinker ·10_ARCHITECTURE.md ·Pipelines compile before they execute. doc @19acdcb4
Pipelines compile before they execute. Planning/config validation belongs in
clinker-plan; runtime operator execution belongs inclinker-exec.
That single rule is the one you’ve now seen from four angles: the typed CompiledPlan
handle (the plan/runtime boundary), the lowering step, and
the plan-vs-exec crate split. Other invariants in the same register:
- The runtime is finite and synchronous: “Do not introduce unbounded streams, daemon/service loops, distributed execution, or async-runtime assumptions without architecture review.”
- Bounded memory is load-bearing. The arbitrator’s 512 MiB default and the spill machinery are a contract, not an implementation detail.
- One YAML chokepoint. All YAML parsing goes through
clinker_plan::yaml, the only place that calls the underlying parser, where input limits are enforced. - Path trust uses proof tokens: APIs that need a trusted path take a
ValidatedPath, not a rawPathBuf.
This is why you check the invariants first. They’re the fixed points your change has to land between.
The crate layering is the boundary you’ll meet most
Section titled “The crate layering is the boundary you’ll meet most”The most frequently-touched boundary is the dependency direction between crates. Dependencies flow one way, “from lower-level vocabulary toward applications”:
clinker ·20_CRATE_MAP.md ·from lower-level vocabulary toward applications doc @19acdcb4
clinker-core-types (leaf vocabulary — no exec/config/schema types)clinker-record → cxl → clinker-format → clinker-plan (parses, validates, lowers → CompiledPlan) → clinker-exec (consumes CompiledPlan, runs operators) → clinker-net → clinker / cxl-cli (the application edges)The rule that polices it:
clinker ·30_DESIGN_RULES.md ·Preserve the current crate layering. doc @19acdcb4
Preserve the current crate layering.
clinker-planparses config, resolves schemas, compiles CXL, validates, and produces a typedExecutionPlanDag; it does not depend on runtime operators.clinker-execconsumes compiled plans and owns runtime dispatch.
You can prove this boundary holds by reading a manifest. clinker-plan’s dependencies
list its lower layers, and pointedly not clinker-exec:
clinker-plan ·Cargo.toml ·clinker-format doc @19acdcb4
[dependencies]clinker-core-types = { workspace = true }clinker-record = { workspace = true }cxl = { workspace = true }clinker-format = { workspace = true }# clinker-exec is absent — plan must not depend on exec.This is why an operator change splits the way it does: the config, plan, and lowering sites
live in clinker-plan; the dispatch arm and operator body live in clinker-exec, which
depends on clinker-plan, never the reverse. If your change tempted you to make
clinker-plan call into a runtime operator, you’d have to add clinker-exec to that
manifest, creating a cycle, and that’s the exact boundary the rule forbids. The manifest is
the boundary made mechanical.
The engine’s three safe seams are a format (FormatReader/FormatWriter), a CXL builtin,
and a transform operator. These are precisely the places where a new change stays inside one layer.
A seam
is the opposite of a boundary break: it’s where the architecture invites your code in.
Some decisions aren’t yours to make: the Decision Gate
Section titled “Some decisions aren’t yours to make: the Decision Gate”The hardest part of planning a change is recognizing when you’ve hit a question you shouldn’t answer unilaterally. Clinker’s workflow names this explicitly: a Decision Gate is opened (and resolved) before implementation, whenever a change depends on an unresolved choice.
clinker ·GITHUB_ISSUE_AGENT_WORKFLOW.md ·Decision Gate doc @19acdcb4
The gate triggers cover:
- new dependency or cargo-deny exception
- public API behavior
- data model, schema, storage, or migration behavior
- auth, security, privacy, or credentials
- performance or bounded-memory tradeoff with unclear priority
- backward compatibility or breaking changes
- cross-crate or cross-service architecture boundaries
- any behavior the agent cannot validate from existing docs, tests, or source
That last bullet is the catch-all and the most important: if you can’t ground the right answer in existing source, tests, or docs, stop and gate it. Don’t guess and encode the guess. Resolving a gate means recording the chosen option, the rejected options, the source evidence, and the consequences. And if the question can’t be resolved, it’s written down rather than silently decided:
clinker ·80_OPEN_QUESTIONS.md ·Open Questions doc @19acdcb4
The open-questions register is where unresolved boundary questions live, things like “what
is the intended boundary between clinker-schema and clinker-plan?” A contributor’s job
is to recognize their change is about to answer one of these, and route it to a gate
instead of deciding it in a pull request.
Scoping a change: Use → Modify → Create
Section titled “Scoping a change: Use → Modify → Create”Change scoping is a repeatable three-question method you run on a change before writing code. Each question reads a real artifact you already have:
- Which boundary does it touch? Read the crate map and the invariant list. Does the change keep dependencies flowing one way? Does it leave compile-before-execute, bounded memory, the YAML chokepoint, and path proof tokens intact?
- What’s the blast radius? List every crate and behavior the change reaches. A change that stays inside one seam has a small radius; one that crosses a crate boundary or changes a public type has a large one.
- Does any gate trigger fire? Hold the change up against the eight triggers. New dependency? Public API or schema? Cross-crate boundary? Anything you can’t ground in existing source? If yes, it’s an escalation, not a direct edit.
The three rungs below walk that method on a real change, then a variant, then one you scope from scratch.
Use: walk the method on a concrete change
Section titled “Use: walk the method on a concrete change”Take a concrete proposal: “add a --max-rows N CLI flag that aborts a run after N
records.” Walk the three questions, reading the real artifacts.
- Boundary. The flag is parsed at the application edge (
clinker/cxl-cli) and the count is checked in the runtime as records flow, which isclinker-exec. Dependencies still flow one way:clinker→clinker-exec. No invariant is touched: the runtime stays finite and synchronous (a row cap, if anything, makes it more bounded), the YAML chokepoint is untouched, no path is involved. - Blast radius. Two crates: argument parsing at the edge, a counter-and-abort check in
the executor’s record loop. No new type crosses a crate boundary; nothing in
clinker-planchanges. - Gate triggers. It’s user-visible CLI behavior: public API behavior and arguably
backward compatibility. A flag that changes how every run can terminate is a behavior
decision, and you can’t fully ground “what should
--max-rows 0do?” or “is an early abort exit code 0 or non-zero?” in existing source. That fires a gate. Not because the code is hard, but because the behavior is a choice.
So the verdict for a tiny flag is: small blast radius, no invariant broken, and still a Decision Gate, because it changes public behavior in a way the source doesn’t already decide. Size of diff is not the signal; which boundaries and which triggers are.
Modify: change one thing and predict the new blast radius
Section titled “Modify: change one thing and predict the new blast radius”Now vary the change. Instead of aborting after N records, suppose the flag is
--sample-rate F: keep only a fraction F of records. One question changes its answer.
Predict which, before revealing.
Create: scope a change from scratch, then verify
Section titled “Create: scope a change from scratch, then verify”Your turn, with no walkthrough. Pick the change: “add a new parquet input format so
pipelines can read .parquet files.” Scope it yourself with the three questions, write
down a boundary, blast-radius, and gate verdict, then open the reveal to check yourself.
💡 Hint 1
Start with the crate map. A format is one of the engine’s three named seams. Which crate
do FormatReader/FormatWriter implementations live in, and does adding one stay inside
that layer? Then ask the dependency question: does a Parquet reader pull in a new
third-party crate?
Scope it, then check yourself
- Boundary. A new format plugs into the
FormatReader/FormatWriterseam inclinker-format, a contained extension point, low in the crate map. The format seam is designed for exactly this, so the layering stays intact: nothing aboveclinker-formatneeds to learn a new boundary. - Blast radius. Mostly one crate (
clinker-format) plus a registration point, except Parquet is a binary columnar format, so a real implementation almost certainly pulls in a new dependency (anarrow/parquetcrate). That dependency, not the reader code, is the largest part of the radius: it brings transitive crates, a license, and a compile-time cost. - Gate triggers. Two fire, clearly. New dependency or cargo-deny exception: a new third-party crate must be justified and pass the license audit. And bounded-memory tradeoff, since columnar readers can buffer whole row-groups, which collides head-on with the bounded-memory invariant; “how does a Parquet reader honor the memory cap?” is a behavior you can’t ground in existing source.
Verdict: even though it lands on a friendly seam with a small code footprint, the new dependency and the bounded-memory question make this a Decision Gate, not a direct PR. The seam keeps the code local; it does not exempt you from the dependency and memory decisions the dependency drags in.
To verify the layering claim against the real tree (in the clinker checkout): the format
seam crate must not depend upward on plan or exec, and clinker-plan must still not depend
on clinker-exec:
# the format seam sits below plan/exec — it depends on neithergrep -E 'clinker-(plan|exec)' crates/clinker-format/Cargo.toml \ || echo 'clinker-format depends on neither plan nor exec — seam is low in the layering'# and the plan→exec boundary still holdsgrep -n 'clinker-exec' crates/clinker-plan/Cargo.toml \ || echo 'clinker-exec absent from clinker-plan deps — layering holds'Reading a change against the boundaries
Section titled “Reading a change against the boundaries”// quick check
Your change makes the aggregation operator faster by having clinker-plan precompute a lookup the operator reads. To do it cleanly you'd add clinker-exec to clinker-plan's dependencies. What's the right move?
'clinker-plan does not depend on runtime operators' is an explicit invariant, and the manifest enforces the one-way arrow. Adding clinker-exec to clinker-plan creates a cycle and breaks the plan/runtime boundary. Cross-crate architecture changes are an explicit Decision Gate trigger: escalate, don't encode the guess. (Copying the code just hides the same coupling by hand.)
Retrieval checkpoint
Section titled “Retrieval checkpoint”Where this leaves you: the capstones
Section titled “Where this leaves you: the capstones”You’ve read the engine end to end, extended each of its seams, and learned to scope a change and land it without breaking a boundary. Two capstones put it together:
- C1, synthetic: add a small extension end-to-end with tests, a new format
(
FormatReader/FormatWriter) or a CXL builtin. It’s stable across revisions and reviewable against a fixed rubric, the safe seams you now know. - C2, real: a genuine, potentially-mergeable contribution selected fresh from the live backlog through the project’s Readiness-Review / Decision-Gate workflow: scoping, review conventions, and architectural reasoning under supervision.
That’s the whole arc of the Engine Track: from “build and run Clinker” to “make a real, boundary-respecting contribution to it.”
Go deeper on the Rust (optional, one-directional; the typed handle behind the
plan/runtime boundary this lesson polices is built on the same Result-returning compile step
the engine’s error-handling lesson covers from first principles):
Glossary terms used: invariant, seam, blast radius, change scoping.