learn / 08·7 — under workflows · upgrades

the gateBEFOREyou deploy

You edit a plan that's already live — scheduled, maybe read by other workbooks. The universal experience is push, then learn at runtime that you broke something downstream. The upgrade gate says compatibility of a plan's surface is a computable fact: exports may grow but never shrink, imports may shrink but never grow, output types are frozen. A kernel answers will this redeploy break a promise before anything runs.

upgrades11 min read
A small inspector figure standing before a monumental backlit customs gate, where one wide arch glows green for caravans flowing outward and a narrow arch burns red against a caravan trying to push back through — 1970s sci-fi style, bright, monumental architecture dwarfing the figure

push and PRAY

A workflow goes live. It's scheduled, it writes a report every night, and somewhere a second workbook reads that report's output. Then you edit the plan — rename a field, change a type, drop a component you thought was unused — and redeploy. The break doesn't show up in your editor. It shows up later, at the far end, when the consumer parses an output that no longer exists.

Every platform answers this the same way: with process. Versioning policy. A changelog. A staging environment. A human promising to be careful. All of it is people agreeing to remember a thing that a machine could simply check. Because here is the claim of this page — compatibility of a plan's surface is not a matter of discipline. It's a computable fact. And if it's computable, a kernel can answer it before the deploy: will this redeploy break a promise the old plan made?

the upgrade GATE

up·grade gate /ˈʌp·ɡreɪd ɡeɪt/ noun

1. a diff of a deployed plan's signature against a new one, where exports may grow but never shrink, imports may shrink but never grow, and a component's output type may never change. Empty diagnostics means the upgrade is safe; anything else is a named, scoped list of promises about to break.

It's a single pure function in the kernelcheck_upgrade(old, new) takes two Org sources, parses each into per-workflow signatures, and returns a JSON array of diagnostics. The gate's whole question, in one line: does everything the old plan promised still hold? Here is every change-kind it judges, and the verdict for each:

change from old to newverdictwhy
an export grows (you add one)safenew consumers become possible; existing ones are untouched
an export is removederrorsomeone downstream may be reading it
a new import is requiredwarna new capability ask — flagged, not forbidden; granting is the engine's call
a component's output type changeserrorevery consumer parses exactly that type
a whole workflow is removederrorpruning it takes its exports with it
a brand-new workflow is addedsafethe old plan promised nothing about it — invisible to the diff
an import is removed (you need less)safedemanding less can't break a host that already granted more

The bottom two rows are the ones every other treatment of this gate leaves out, and they aren't an oversight — they fall straight out of how the loop is written. The gate iterates the old workflows only. A workflow that exists solely in the new plan is never visited, so adding one is invisible. And a dropped import is the absence of a thing the loop checks for, so it's silent too. The gate doesn't ask whether the plan changed. It asks whether the old plan's promises survived.

give grows, demand SHRINKS

The three rules point in opposite directions, and that's the whole idea. What a plan gives — its exports — may grow but never shrink. What a plan demands — its imports, its capabilities — may shrink but never grow. Output types, the contract on each given thing, may not move at all.

The reason is who depends on which. A consumer reads your exports, so taking one away breaks the consumer; adding one can't. A host grants your imports, so demanding a new one might exceed what the host is willing to give; demanding fewer never does. Said with the jargon once and then never again: exports are covariant (the new set must contain the old), imports are contravariant (the old set must contain the new). One direction of every edit is free; its mirror image is gated.

flowchart TB
  subgraph old["the OLD plan — already deployed, already promised"]
    oe["exports it gives
report:string"] oi["imports it demands
workbook:fs"] end subgraph new["the NEW plan — what you're about to push"] ne["exports it gives"] ni["imports it demands"] end oe -- "add more exports" --> ne ne -. "drop an export" .-> oe oi -. "demand a new import" .-> ni ni -- "demand fewer imports" --> oi linkStyle 0 stroke:#13d943,stroke-width:3px linkStyle 3 stroke:#13d943,stroke-width:3px linkStyle 1 stroke:#d9433f,stroke-width:3px linkStyle 2 stroke:#d9433f,stroke-width:3px style old fill:#fbfaf6,stroke:#121316 style new fill:#fbfaf6,stroke:#121316 style oe fill:#aee5c2,stroke:#121316 style ne fill:#aee5c2,stroke:#121316 style oi fill:#f3c5a3,stroke:#121316 style ni fill:#f3c5a3,stroke:#121316

Read the diagram as two green arrows and two red. Green runs left-to-right on the exports — add more, always fine — and right-to-left on the imports — demand fewer, always fine. Red is each one reversed: drop an export, or demand a new import, and the gate lights up. The shape is a one-way valve in each row, and the two valves face opposite ways. That opposition is the entire safety property, drawn once.

the Motoko DEBT

These rules are borrowed, and the kernel says so in its own comments — the gate is labelled job 4: upgrade-compatibility gate, Motoko-style. The lineage is the Internet Computer: canisters there are upgraded in place, and Candid — the IC's interface language — runs a service-subtype check that refuses an upgrade whose new interface isn't a structural subtype of the old. Same asymmetry: a service may offer more methods and demand less, never the reverse. Motoko, the IC's language, pairs that with stable variables — state declared to survive an upgrade. We borrow both ideas, and the second half of this lesson is the second borrowing.

What we keep is the shape: the covariance of what you offer, the contravariance of what you require, and the insistence that the check runs before the upgrade commits. What we simplify is everything underneath. Candid compares types through a real subtype lattice — a record with more fields is a subtype, a variant with fewer is. Our kernel does no such thing. It compares output types as whole strings. The analogy to Candid lives at the level of sets — which exports exist, which imports exist — not at the level of types. That's a deliberate floor, and the next sections are honest about where it sits.

one diff, three DIAGNOSTICS

The kernel ships a worked demo, and it runs end to end. Start with a one-component plan — version one:

* Report                                            :workflow:
** Build                                            :component:
   #+begin_src rust :out report:string
   #+end_src

Version two changes exactly one line. The Build component starts emailing, and its output flips from a string to json:

** Build                                            :component:
   #+begin_src rust :uses workbook:net/email :out report:json
   #+end_src

Run Workbooks.OQL.check_upgrade(v1, v2) in iex — or call the demo, Workbooks.Demos.Kernel.demo_upgrade() — and three diagnostics come back:

[{"level":"error","scope":"Report","message":"export `report:string` removed (breaking)"},
 {"level":"warn","scope":"Report","message":"new capability `workbook:net/email` now required"},
 {"level":"error","scope":"Report","message":"component `Build` output type changed (breaking)"}]

One edit, three findings — and the double-count is honest, not a bug. Changing report:string to report:json is simultaneously an export removal (the old export report:string is gone) and an output-type change on Build. Both fire because both audiences are real: whoever consumed the named export report:string, and whoever parsed Build's output type. The middle finding is the only warn in the set — the new workbook:net/email import is a capability the plan now asks for, and whether to grant it is the engine's decision, not the gate's. So the gate flags it and moves on. Here's the order the kernel works in:

sequenceDiagram
  participant C as caller
  participant K as check_upgrade
  participant S as world_sigs
  C->>K: old source, new source
  K->>S: world_sigs(old)
  S-->>K: {Report: signature}
  K->>S: world_sigs(new)
  S-->>K: {Report: signature}
  Note over K: iterate OLD workflows only
  K->>K: pass 1 — exports removed? → error report:string
  K->>K: pass 2 — new imports? → warn net/email
  K->>K: pass 3 — output types moved? → error Build
  K-->>C: three diagnostics, scope Report
  

Read the sequence as a pipeline. The caller hands in two Org sources. The kernel computes a signature for the old plan and a signature for the new, each a map keyed by workflow title. Then it walks the old map — and only the old map — applying three passes in turn: anything the old plan exported that the new one doesn't, every import the new plan added that the old lacked, and every component whose output type differs. Each failure becomes one diagnostic, scoped to the workflow Report, with the component name carried inside the message. The result is data, not an exception — what the caller does with it is the next question.

what gets COMPARED depth rung — skippable

The thing being diffed is a Sig — a signature — and one is computed per workflow by a function called world_sigs. Two facts about it decide the gate's whole character.

Signatures are keyed by workflow title. The map from which the diff reads uses the workflow's headline text as its key. This is why a rename reads as a break: change Report to Summary and the old Report is missing from the new map — removed — while Summary is a stranger the old plan never promised. Two workflows that happen to share a title collide in the map, last one wins. Title is identity here, with everything that implies.

Signatures are computed flat. A workflow's signature is taken over every component descendant — it walks past intervening sub-workflows and collects them all. This is the opposite lens from the one the compiled plan uses, which scopes each world to its direct children for execution. Same tree, two readings: scoped for running, flat for the safety diff. The flat reading is deliberate — a parent answers for the promises of its sub-worlds, so pruning a buried subtree shows up as the parent losing that subtree's exports. The composition half of this gate — grafting and pruning whole subtrees — lives in the nesting lesson; this page is the surface-diff half.

Sig fieldhow it's computedwhich rule reads it
importssorted, deduped union of every component's :usesthe new-import warn
exportsevery :out not consumed by another component's :in inside the worldthe export-removed error
comp_outa map from component name to its declared :out (or none)the output-type-changed error
edgesthe in→out wiring between componentscomputed, but no rule consumes it

The export rule is subtler than it looks, and the table names why: an output is an export only if nothing else inside the same world consumes it. Wire one component's :out into another's :in and that value stops being a promise to the outside — it's now internal plumbing. Consumption is encapsulation. Hold onto that; it sets a trap two sections from now.

what the gate cannot SEE depth rung

The gate diffs a plan's surface, and only its surface. That word is load- bearing. Everything below the surface is invisible to it — by construction, because the loop only iterates old workflows and only inspects four fields. Here is the honest list of what a clean diff does not promise:

change you madecaught?why not
you rewrote a component's source bodynothe gate never reads the implementation
you switched a component from rust to jsnolanguage isn't part of the signature
you changed a component's :depsnodependencies aren't compared
you rewired the edges between componentsnoedges are computed but no rule reads them
you changed the schedulenoscheduling lives outside the signature
you removed a component whose output was internalnoa consumed output was never an export
you added a whole new componentnonew things break no old promise
:out report:string:out report:jsonyeswhole-string inequality — but only string equality, not subtyping

Two cautions follow from that last row. First, the type check is string equality, full stop. report:string and report:json differ because the strings differ — there is no notion that one widens or narrows the other, no lattice, no Candid-style record subtyping. A type that's genuinely compatible but spelled differently still trips the gate, and a type that's spelled the same but means something new sails through. Second, and larger: surface is not behavior. A component can keep every export, every type, every import, and have its body rewritten to do the opposite of what it did — the gate sees nothing. This is the same line the grammar draws everywhere: structure is checkable, prose and logic are not. The gate polices the contract, never the conduct behind it.

the state that CROSSES

A gate that approves a redeploy is only half the question. The other half is what survives it. When the new plan replaces the old, components are torn down and re-instantiated — they're stateless between runs by design. So what about the component that was holding something worth keeping? That's the second Motoko borrowing: :persist, the same idea as stable variables, at the same plan granularity.

:persist is a bare flag in a component's source header — no value, just the word. It parses into the plan as a boolean per component, and a runtime function, Workbooks.Lifecycle.durable_components, walks the plan — worlds and nested sub-workflows, recursively — and returns the names that declared it. The kernel's demo makes it concrete:

* Agent                                             :workflow:
** Remember                                         :component:
   #+begin_src rust :persist :out memory:json
   #+end_src
** Act                                              :component:
   #+begin_src js :in memory:json
   #+end_src

Call durable_components on that plan and you get back a one-element list: ["Remember"]. Remember opted into durability; Act consumes its memory but declares nothing of its own. Now the substrate fact, which is the part people get wrong: :persist is not a memory snapshot. The state doesn't live in raw linear memory that the runtime freezes and thaws — it lives in the VFS, the workbook's own SQLite-backed disk. :persist is the contract that a component checkpoints its state into that disk rather than relying on a memory image. Because components are re-instantiated stateless on every start, a memory snapshot would be the wrong primitive — the disk is the right one.

flowchart LR
  oi["old instance
Remember — holds memory"] subgraph vfs["the VFS — the durable substrate"] direction TB m["memory volume — persists"] w["workspace volume — persists"] t["tmp volume — cleared on resume"] end ni["new instance
Remember — re-reads memory"] oi -- "checkpoint :persist state" --> m oi -- "scratch" --> t m -- "resume copies it back" --> ni t -. "gone" .-> ni gate{{"upgrade gate
approves the redeploy"}} oi --> gate --> ni style vfs fill:#fbfaf6,stroke:#121316 style m fill:#aee5c2,stroke:#121316 style w fill:#aee5c2,stroke:#121316 style t fill:#f3c5a3,stroke:#121316 style oi fill:#ffffff,stroke:#121316 style ni fill:#ffffff,stroke:#121316 style gate fill:#f2ddb0,stroke:#121316

The durability machinery underneath is real. The runtime's freeze copies the workbook's SQLite file to cold storage; resume copies it back, and in the act clears the tmp volume while memory and workspace persist. Litestream replicates the file to S3 or R2 out of band. Read the diagram as a hand-off: the old Remember instance checkpoints its state into the durable memory volume and throws its scratch into tmp; the gate approves the swap; the new Remember instance resumes — memory and workspace come back, tmp is gone. The flag is how the runtime knows who to carry across.

a gate with no HANDS depth rung

One detail closes the loop and explains the gate's reach. check-upgrade is a pure WIT export — its whole world is string-in, string-out, with no host imports and no WASI context. It can't touch a disk, can't open a socket, can't refuse anything. A fault inside it traps within Wasmtime and goes nowhere. It is a function that computes a verdict and hands it back, and that is all it can do.

That purity is exactly why it travels. The same logic is linked natively into the wb CLI as a plain Rust pub fn, and embedded into the BEAM runtime as a compiled component loaded at startup — one logic, two surfaces, with no separate wasm version of the rule. And it's why refusing is necessarily the caller's job. A gate with no hands can't stop a deploy; it can only tell the truth about it. Whoever holds the deploy path reads the diagnostics and decides. The gate that polices every component's surface is itself a WIT-typed component — it rides the very model it imposes.

where it BITES

Honesty section, and the most important one. This page describes a gate that exists and wiring it is waiting on. Do not read it as a promise that your redeploys are gated today. They aren't yet. Here is the exact ledger.

Callable today: the kernel export Workbooks.OQL.check_upgrade/2 and the demo Workbooks.Demos.Kernel.demo_upgrade. You can reproduce every diagnostic on this page in an iex session right now.

Not wired: no deploy path calls check_upgrade. The wb CLI exposes query, tangle, and lint — there is no check-upgrade verb. The HTTP and WebSocket dispatchers expose tangle and validate only. Nothing stops at the gate on its way to production. The gate is a kernel export with a runtime wrapper; turning it into a tollbooth is the runtime's job, not yet done.

Persistence, same status: durable_components is also called only by the demo. The plan carries the :persist flag, the Lifecycle module can enumerate who set it, but the run path doesn't yet checkpoint on anyone's behalf automatically. Enumerable today; not auto-enforced.

And two sharp edges in the gate as written: renames read as remove-plus-add because title is identity, and two same-titled workflows collide in the signature map with last-write-wins. These are boundaries of the current design, stated as boundaries — not apologies. The composition counterpart to all of this, grafting and pruning whole subtrees, is treated in the nesting lesson.

questions people actually ASK

Is this just semver?

No. Semver is a promise a human makes by hand — they decide whether a change is major and write a number. This is a diff a kernel computes from the two plans themselves. There's no judgment to forget and no number to get wrong; the asymmetry rules either hold or they produce a named list of what broke.

Why is a new import only a warn, not an error?

Because a new import is a request, not a break. The plan is asking for a capability it didn't ask for before — say, network email. Whether to grant that is the engine's policy decision, made where capabilities are granted, not the gate's. So the gate surfaces it and leaves the call to the caller.

What if I rename a workflow?

The gate reads it as removing the old one and adding a new one — breaking. Signatures are keyed by the workflow's title, so the title is the identity. A rename and a genuine deletion-plus-creation are indistinguishable to the diff. Today there's no alias or stable id to tell them apart.

Does :persist snapshot my component's memory?

No — and the difference matters. Components are re-instantiated stateless on every start, so there's no live memory image to snapshot. :persist is the contract that the component checkpoints its state into the VFS, the workbook's SQLite disk, which the runtime freezes and resumes around an upgrade. State rides the disk, not a memory dump.

Can I run the gate today?

You can call it — Workbooks.OQL.check_upgrade/2 works, and the demo runs every example here. What you can't do today is have it automatically stop a deploy. No deploy path consults it yet; that wiring is the open piece.

Does it check that my code actually changed?

No. The gate diffs the surface — exports, imports, output types, by workflow title. It never reads a component's body, language, or dependencies. You can rewrite a component to do the opposite of what it did and, as long as the surface holds, the gate returns empty. Surface compatibility is not behavioral verification.

keep GOING

This sub-lesson sits under workflows; the gate's composition half lives in nesting, and its persistence substrate is the VFS.