Module 3: Contracts Between Workflows

6. Versioning a contract without breaking its callers

Description

By the end of this lesson you'll be able to tell, for any change you want to make to a contract, whether it's compatible —doesn't break whoever already calls it— or breaking —breaks it—; you're going to know how to make a compatible change safely and a breaking change with no crashes, running two versions side by side and gradually migrating the callers; and you're going to know how to find who calls a sub-workflow before touching it. It's the piece the contract was missing to be complete: not just defined, declared, and validated, but capable of evolving without repeating lesson 1's silent break.

This matters because contracts aren't eternal. The business changes: Cumbre adds a new currency, check-credit needs to return one more piece of data, a field turns out to need a different type. The day you need to change a contract is this entire module's most dangerous day, because a careless change to a contract with several callers doesn't break one, it breaks all of them —and silently, as you saw from the very first lesson—. Versioning is the discipline that turns that dangerous day into a controlled change.

Connection to the module: lesson 1 showed you the silent break —someone renames approved and order-triage breaks with not a single error—. The whole module, up to here, has been about building the contract so you can protect it. This lesson closes the loop: how to change it without causing that break. It rests on lesson 2's kitchen/window distinction (only changes to the window break), on lesson 3's schema (which you compare against to know whether a change breaks), and on lesson 5's validation (which has to be updated alongside the contract). Lesson 8 has you build, as part of the project, a second compatible version of check-credit, applying exactly what's here.

The menu that evolves without leaving diners hungry

Let's go back to lesson 1's restaurant, because its menu is the exact image of how a contract changes well or changes badly.

A restaurant that's been open for years changes its menu all the time, and yet its regulars never end up unable to order. How? Because there are two kinds of menu change, and the restaurant knows which is which. When it adds a new dish —a dessert that wasn't there before—, no customer is affected: the ones who ordered their usual keep ordering it the same way, and the ones who want the new dessert can now get it. It's a change that only adds. But when it renames a dish —"american coffee" becomes "house coffee"— or when it removes one from the menu, then it does break someone: the customer who walks in and orders "an americano" gets nothing, because the name they knew to order it by no longer exists. The dish might still be in the kitchen, exactly the same; but from the customer's point of view, the promise they were counting on disappeared.

This is the lesson's entire distinction, and it fits in two words: adding is safe, removing and renaming break. A change that only adds something optional —a new dish, a new field nobody's obligated to use— doesn't affect whoever was already ordering their usual. A change that removes, renames, or changes what already existed pulls the rug out from under everyone who depended on it. The first kind are called compatible changes; the second, breaking changes. And the entire discipline of versioning consists of knowing which of the two whatever you're about to do belongs to, and treating each as it deserves.

The taxonomy: what breaks and what doesn't

Let's put the distinction into a concrete table, because the intuition "adding is safe" has nuances worth facing head-on. For each change to check-credit's contract, here's what happens to an old caller that never heard about the change:

Change to the contractDoes it break an old caller?Why
Adding an optional input field with a defaultNo — compatibleThe old caller doesn't send it, and the default handles it. Everything stays the same.
Adding a new output fieldNo — compatibleThe old caller simply ignores the new field; it reads what it already read.
Renaming a field (input or output)Yes — breakingThe old caller writes/reads the old name, which no longer exists. The silent break.
Removing a fieldYes — breakingThe caller that depended on it is left with nothing.
Changing a field's typeYes — breakingThe caller sends/expects the old type; the new one doesn't fit.
Making an optional field requiredYes — breakingThe old caller, which didn't send it, now fails validation.
Adding a required input fieldYes — breakingThe old caller doesn't send it, and now it's required: it fails.
Changing the output envelope's shape (from ok to status)Yes — breakingThe caller reads the old discriminator, which no longer exists.

Notice the pattern running through the whole table. What's compatible has a common signature: it adds something the old caller can ignore. An optional input field (it ignores it and the default covers it), a new output field (it ignores it and reads its own). What's breaking has the opposite signature: it changes or removes something the old caller was counting on. The rename, the deletion, the type change, tightening a rule —all of them pull the rug out from under an existing expectation—.

From here comes a quick mental test you can apply to any change, with no need to memorize the table: "does a caller that doesn't find out about this change keep working the same?" If the answer is yes, it's compatible. If it's no, it's breaking. The caller "that doesn't find out" is the key: a compatible change is invisible to whoever doesn't need it; a breaking one gets imposed on them whether they like it or not.

An important nuance the table hides: adding a required input field is breaking, even though "adding" sounds compatible. The word "adding" isn't enough; what decides is whether the old caller can keep going unchanged. An optional input field can be ignored; a required one forces a change. That's why the safe way to add a new input is almost always adding it optional with a default, and only making it required later, with a migration —which, as the table says, is already a breaking change in itself—.

The compatible change: how to add without breaking

Let's start with the easy case, because it has its own technique and it's worth doing right. Say Cumbre wants check-credit to be able to, optionally, also return the customer's credit history —but only when the caller asks for it, because computing it is expensive and most callers don't need it—.

The compatible way to do it has two parts:

On the input, an optional field with a default. You add include_history as an optional input field, of type boolean, with default false. An old caller that doesn't send include_history gets the default false —the usual behavior, no history—. A new caller that wants the history sends include_history: true. Nobody breaks: the old one doesn't even notice, the new one has what it needs.

On the output, a new field that only shows up when it applies. When include_history is true, the success response includes an extra credit_history field. When it's false, it doesn't show up. An old caller reads approved and available_credit as always, and completely ignores credit_history —doesn't even know it exists—. A new output field never breaks anyone, because nobody's obligated to read it.

The evolved contract looks like this, with the new parts marked:

SCHEMA — check-credit (evolved, COMPATIBLE change)

INPUT
  customer_id     : string   required
  order_id        : string   required
  amount          : number   required
  currency        : string   optional   (default: "MXN")
  include_history : boolean   optional   (default: false)   ← NEW, optional

SUCCESS OUTPUT
  { ok: true, customer_id, approved, available_credit,
    credit_history?: [...] }                               ← NEW, only if include_history=true

What to expect. After this change, order-triage —which calls check-credit without sending include_history and only reads approved— keeps working exactly the same, with nobody touching it. It doesn't fail, doesn't change behavior, doesn't even find out the contract grew. That's the mark of a compatible change: old callers go on with their lives unaware, and new ones have one more capability. You were able to evolve the contract with no coordination, no migration, no risk. That's how you add well.

A note connecting to lesson 5: when you add include_history, you also update the validation to account for it —that if it arrives, it's boolean; that if it doesn't, it takes the default—. The contract and its validation change together, always. An evolved contract with validation left on the previous version is a crack waiting to open.

The breaking change: how to do it with no crashes

Now the hard case, the one that truly demands discipline. Say Cumbre discovers amount should always have been an object with the amount and currency together —{ value: 1842.50, currency: "MXN" }— instead of a loose number with the currency in a separate field. Changing amount from number to an object is, per the table, breaking: every caller sending amount as a number would stop fitting.

The temptation is to make the change "all at once" —modify check-credit, announce over chat "heads up, I changed amount," and rush to update the callers before someone notices—. That's exactly the recipe for the silent break at a bigger scale: between when you change the sub-workflow and when you finish updating every caller, there's a window in which the ones you haven't migrated yet are broken. And in a real system that window can last days.

The correct way doesn't touch the old contract. It's called parallel versions, and the cycle has four steps:

Step 1 — Create a new version alongside, without touching the old one. You duplicate check-credit into a new sub-workflow, check-credit-v2, with the new contract (amount as an object). The original check-credit —let's call it v1 from here on— stays intact, working, with its old contract. At this moment both versions exist: old callers keep calling v1 and nothing broke; v2 is ready for whoever wants the new contract.

Step 2 — Migrate the callers, one by one, when you can. You switch each caller from v1 to v2 at your own pace, testing each one end to end as you migrate it. There's no rush and no breaking window: as long as a caller isn't ready, it keeps calmly using v1. You migrate order-triage today, another caller next week. Each migration is a small, verified change, not a collective leap into the void.

Step 3 — Mark v1 as deprecated. When you no longer want new callers of v1 to show up, you mark it deprecated —a notice in its Sticky Note: "DEPRECATED, use check-credit-v2, retiring September 30th"—. Deprecating isn't deleting; it's announcing this is going away and giving time. v1 keeps working for whoever hasn't migrated yet.

Step 4 — Retire v1 once nobody calls it anymore. Only once you've confirmed no caller uses v1 —the next section tells you how to confirm it— do you delete it. Now the system is clean, with a single version, and there was never a breaking window.

Breaking change timeline:

v1 ──────────────────────────────────●  (retired, once nobody calls it anymore)
                                     /
v2       ●──────────────────────────    (created alongside; callers migrate one at a time)
         │        │         │
      created  order-triage others
               migrated     migrated

What to expect. Throughout the entire migration, the system never stops working. At every moment, each caller is pointing to a version that serves it —v1 if it hasn't migrated yet, v2 if it has—, and none is left pointing to a contract that got changed out from under it. The breaking change happened, but it was spread over small, verified steps instead of a collective leap. Compared to "change it and rush to fix things," the difference isn't stylistic: it's the difference between a change with no crashes and a window of hours or days where part of the system is silently broken.

Finding the callers before touching anything

All of the above depends on a question you have to be able to answer before changing any contract: who calls this sub-workflow? If you don't know who the callers are, you can't know whether a change breaks them, can't migrate them, and can't confirm nobody uses the old version anymore before deleting it.

n8n helps with this. When you open a sub-workflow, the interface can show you which other workflows call it —the relationship between an Execute Sub-workflow and the sub-workflow it invokes is visible—. Worth checking on your version how exactly that information is shown, because the interface changes; the point is the dependency isn't invisible: n8n knows who calls whom, because the Execute Sub-workflow node explicitly names its sub-workflow.

But don't rely only on the tool. The discipline that truly sustains this is documenting the callers in the contract itself. In check-credit's Sticky Note, next to the schema, it's worth having a "Who calls me" list: order-triage, and any others. That way, whoever's about to change the contract sees immediately who they need to consider, with no dependence on memory or manual tracing. It's the same philosophy running through the whole module: the information you need to not break something lives glued to that something, not in the memory of whoever wrote it.

The operating rule: before any breaking change, list every caller. If the change is compatible, the list reassures you (nobody breaks). If it's breaking, the list is your migration plan: it's exactly the workflows you have to move from v1 to v2. And before deleting v1, the list has to be empty —zero callers— or you're about to break someone.

Two reminders that save you versions

Before closing, two observations that keep you from versioning more than needed —because versioning has a cost, and not every change needs it—.

The first: most changes don't touch the contract. Go back to lesson 2's kitchen/window distinction. Everything this module calls a "breaking change" is a change to the window —to what crosses the boundary—. But most of the work of maintaining a sub-workflow is changing the kitchen: improving how it calculates the credit, switching the data source from a sheet to a database, optimizing internal nodes. None of that touches the contract, so none of it needs versioning. Before starting the parallel-versions cycle, ask yourself lesson 2's question: "does what I'm about to change cross the boundary?" If the answer is no —if it only changes how the sub-workflow works internally—, change it freely, no version, no migration, no need to tell anyone. Versioning is for the window; the kitchen changes for free. Confusing a kitchen change with a window change makes you set up an expensive migration for nothing.

The second: sometimes a single version can accept both shapes. Parallel versions (v1 and v2) are the cleanest route for a breaking change, but not the only one. For some changes there's a lighter technique: making the sub-workflow, during the transition, accept both the old shape and the new one. Let's go back to amount switching from a number to an { value, currency } object. Instead of creating check-credit-v2, you can adjust check-credit's validation to accept both shapes: if amount arrives as a number, treat it as before; if it arrives as an object, use the new shape. That way, old callers keep sending a number and don't break, and new ones can already send the object —with no second workflow created—.

// Validation fragment, during the transition: accepts both shapes of amount.
let amountValue;
let amountCurrency;
if (typeof input.amount === 'number') {
  // Old shape: amount is a number, currency comes separately (or its default).
  amountValue = input.amount;
  amountCurrency = input.currency ?? 'MXN';
} else if (input.amount && typeof input.amount === 'object') {
  // New shape: amount is an object { value, currency }.
  amountValue = input.amount.value;
  amountCurrency = input.amount.currency ?? 'MXN';
} else {
  errors.push('amount must be a number or an object { value, currency }');
}

This technique —tolerating both the old and new input at once— turns a breaking change into a temporarily compatible one, and saves you the second workflow. Its cost is that the sub-workflow gets more complex internally while the transition lasts, with logic for two shapes; that's why it's used as a bridge, not as a final state. Once you confirm no caller sends the old shape anymore, you remove the old branch and the contract ends up clean, in a single shape. It's the same goal as parallel versions —migrating with no breaking window— via a different route: instead of two workflows, one tolerant workflow during the transition. Choose based on the case: parallel versions when the change is large or the two-shape logic would be tangled; a tolerant workflow when the change is bounded and accepting both shapes is simple.

Common mistakes

Calling a breaking change "small" because the code changed little (conceptual). What happens: someone renames an output field from available_credit to remaining_credit —"it's just one field, a tiny change"— and does it directly on the contract in use; callers that read available_credit break silently. Why it happens: the size of a change gets instinctively measured by how much code was touched, and renaming a field touches almost nothing. But the size that matters isn't how much the sub-workflow changed, it's how many callers depended on what changed. How to spot it: apply the mental test —"does a caller that doesn't find out keep working?"—; if the answer is no, the change is breaking no matter how small it looks. How to fix it: treat every breaking change, however small it looks in code, with the parallel-versions cycle; renaming a single field deserves the same care as redesigning the whole output, because it breaks just the same.

Changing the contract and updating the callers "on the fly" (practical). What happens: someone modifies check-credit directly, and starts rushing to update order-triage and the other callers one by one while the sub-workflow has already changed; during that time, the unmigrated callers are broken. Why it happens: it feels faster to make a single change and "fix whatever comes up" than to create a parallel version; the cost —the breaking window— isn't visible until an order falls into it. How to spot it: if your change plan includes a phrase like "and then I'll quickly fix the callers," you have a breaking window. How to fix it: never change a contract in use in place. Create the new version alongside (check-credit-v2), migrate the callers at your own pace, and retire the old one only once it's unused. The parallel version costs a bit more work and eliminates the window entirely.

Deleting the old version without confirming nobody calls it (practical). What happens: someone migrated "all" the callers to v2, marked v1 deprecated, and a week later deletes it for cleanliness; it turns out a forgotten workflow was still calling v1, and it now fails. Why it happens: "I think I migrated everyone" isn't the same as "I confirmed nobody calls v1"; memory fails and there's always a forgotten caller. How to spot it: before deleting, review v1's actual caller list —in n8n's interface and in the "who calls me" Sticky Note—; if it isn't empty, or if you can't confirm it, don't delete. How to fix it: retire a version only once its caller list is verifiably empty. Deprecating gives you the time to reach that zero; deleting before confirming it turns a cleanup into a crash.

Exercises

Exercise 1 — Compatible or breaking. For each change to check-credit's contract, decide whether it's compatible or breaking, applying the mental test "does a caller that doesn't find out keep working?":

(a) Adding an output field checked_at with the query's date. (b) Renaming the input field amount to order_amount. (c) Adding an optional input field notify_on_reject with default false. (d) Making the currency field required, which used to be optional. (e) Changing the output discriminator from ok: true/false to status: "success"/"error".

See solution

(a) Compatible. A new output field; the old caller ignores it and reads the ones it already read. It doesn't find out, stays the same.

(b) Breaking. Renaming an input field: the old caller keeps sending amount, which no longer exists under that name; the new order_amount arrives empty for the sub-workflow. It fails.

(c) Compatible. Optional input with a default; the old caller doesn't send it, the default covers it. It doesn't find out.

(d) Breaking. Making an optional required: the old caller, which didn't send currency, now fails validation for not sending it. A new obligation got imposed on it.

(e) Breaking. Changing the envelope's shape: the caller reads ok to know whether it was a success, and ok no longer exists; now there's status, which it isn't reading. It fails to interpret every response.

Why this works: the mental test separates all of them with no need to memorize the table. (a) and (c) add something ignorable —the caller that doesn't find out stays the same—; (b), (d), and (e) change something the caller was counting on —forcing it to change whether it wants to or not—. Notice (d) has the trap of "I didn't add or remove, I only changed a flag": tightening a rule (optional → required) is breaking even though it doesn't touch the name or the type.

Exercise 2 — Turn a breaking change into a compatible one. Cumbre wants check-credit to also receive the channel the order came through (web, whatsapp, rep_csv), to apply different credit rules per channel. The first idea is adding channel as a required input field. That's breaking. How do you add it compatibly, and what cost does that option have?

See solution

You add it as an optional field with a sensible default, not required. For example, channel : string optional (default: "web"). That way, an old caller that doesn't send channel gets the default "web" and keeps working; a new caller can send the real channel. The change goes from breaking to compatible.

The cost: the "web" default is an assumption. If an old caller was actually processing whatsapp orders but doesn't send the field, check-credit is going to treat it as web and apply the wrong rules to it —silently correct in form, incorrect in business terms—. That's the price of compatibility: to avoid breaking anyone, you assume a value for those who don't send it, and that value might not be theirs.

That's why, when the new field truly has to be correct for every caller —when no default is safe—, the compatible route isn't enough and you need a properly-done breaking change: a parallel version (check-credit-v2 with channel required) and migrating each caller to send its real channel. The rule: add optional with a default when a safe default exists; go to a parallel version when there isn't one.

Why this works: the exercise shows "make it compatible" isn't free or always correct. A required field can be made compatible by making it optional with a default, but the default introduces an assumption that could be false for some caller. Knowing when that assumption is acceptable (there's a safe default) and when it isn't (every caller needs its real value) is the underlying design decision behind compatible-vs-breaking.

Exercise 3 — Order the migration. You have to change amount from number to an { value, currency } object in check-credit —a breaking change—. check-credit (v1) is currently called by three workflows: order-triage, bulk-order-import, and credit-report. Order the parallel-versions migration steps, and say at what point it's safe to delete v1.

See solution
  1. Create check-credit-v2 with the new contract (amount as an object), without touching check-credit v1. Now both exist; all three callers stay on v1, nothing broke.
  2. List and confirm v1's callers: order-triage, bulk-order-import, credit-report. Those three are the migration plan.
  3. Migrate the callers one by one, at your own pace, testing each end to end as you change it: first order-triage (send it amount as an object and verify the whole flow is still fine), then bulk-order-import, then credit-report. As long as a workflow isn't migrated, it keeps using v1 with no problem.
  4. Mark v1 as deprecated once all three are migrated, with a retirement date, to catch any forgotten or new caller.
  5. Confirm v1's caller list is empty —zero workflows pointing to v1—.
  6. Delete v1 only then.

It's safe to delete v1 only at step 6: when you've confirmed (not just "I think") that none of the three —nor any other— is still calling it. If you delete it at step 4, trusting that "I already migrated all three," you risk a forgotten caller —or one created while you were migrating— falling into the gap.

Why this works: the sequence guarantees that at no point is a caller left pointing to a contract that got changed out from under it. The key is that v2's creation (step 1) and v1's deletion (step 6) are at the extremes, and the whole migration happens in between with both versions alive. The classic mistake —deleting v1 as soon as you believe you finished migrating— gets avoided with step 5: confirming the zero, not assuming it.

Summary and next step

In this lesson you closed the contract's cycle: you learned to change it without repeating lesson 1's silent break. You saw the restaurant's menu evolving without leaving anyone hungry, and distilled the entire distinction into two words: adding is safe, removing and renaming break. You separated compatible changes —which add something the old caller can ignore, like an optional input field with a default or a new output field— from breaking ones —which change or remove something the caller was counting on, like renaming, deleting, changing a type, or tightening a rule—, with the mental test "does a caller that doesn't find out keep working?" as the single criterion. You made a compatible change well —an optional include_history with a default, plus an output field that only shows up when requested— with no coordination needed with anyone. And you learned to make a breaking change with no crashes with the parallel-versions cycle: creating v2 alongside, migrating the callers one by one, deprecating v1, and retiring it only once its caller list is verifiably empty. You closed with the question everything above needs: who calls this sub-workflow, answered with n8n's help and with the discipline of documenting callers glued to the contract.

Before moving on to lesson 7 you should be able to: classify any change as compatible or breaking with the mental test; make a compatible change with an optional field and a default; and order the migration for a breaking change with parallel versions, knowing when it's safe to delete the old version.

Up to here, your contracts' caller was always another workflow —order-triage calling check-credit—. But there's a new, increasingly common kind of caller that consumes your workflow a different way: an AI Agent or an MCP client that treats your workflow as a tool. For that caller, the contract doesn't look like fields in an Execute Sub-workflow node; it looks like a natural-language description plus an input schema, and a clear contract is literally what makes the agent use the tool correctly. Lesson 7 takes everything you learned about contracts into that territory.

Resources

  • Sub-workflows — n8n Docs — how one workflow calls another and what the relationship between an Execute Sub-workflow and the sub-workflow it invokes looks like, the foundation for finding callers.
  • Execute Sub-workflow Trigger — n8n Docs — the node where the schema you're going to evolve lives; useful for seeing how input fields get added or changed.
  • Sticky notes — n8n Docs — where to document the contract's version, the caller list, and deprecation notices, glued to the workflow.
  • Semantic Versioning — the industry-standard convention for naming versions by telling compatible changes apart from breaking ones; this lesson's same criterion, formalized.