Skip to content

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.

  • Name three of the engine’s architectural invariants and say where each is written down.
  • Read a crate’s Cargo.toml and 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.

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 in clinker-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 raw PathBuf.

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-plan parses config, resolves schemas, compiles CXL, validates, and produces a typed ExecutionPlanDag; it does not depend on runtime operators. clinker-exec consumes 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:

  1. 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?
  2. 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.
  3. 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.

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 is clinker-exec. Dependencies still flow one way: clinkerclinker-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-plan changes.
  • 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 0 do?” 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/FormatWriter seam in clinker-format, a contained extension point, low in the crate map. The format seam is designed for exactly this, so the layering stays intact: nothing above clinker-format needs 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 (an arrow/parquet crate). 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:

Terminal window
# the format seam sits below plan/exec — it depends on neither
grep -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 holds
grep -n 'clinker-exec' crates/clinker-plan/Cargo.toml \
|| echo 'clinker-exec absent from clinker-plan deps — layering holds'

// 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?

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.