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
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 kernel —
check_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 new | verdict | why |
|---|---|---|
| an export grows (you add one) | safe | new consumers become possible; existing ones are untouched |
| an export is removed | error | someone downstream may be reading it |
| a new import is required | warn | a new capability ask — flagged, not forbidden; granting is the engine's call |
| a component's output type changes | error | every consumer parses exactly that type |
| a whole workflow is removed | error | pruning it takes its exports with it |
| a brand-new workflow is added | safe | the old plan promised nothing about it — invisible to the diff |
| an import is removed (you need less) | safe | demanding 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 field | how it's computed | which rule reads it |
|---|---|---|
| imports | sorted, deduped union of every component's :uses | the new-import warn |
| exports | every :out not consumed by another component's :in inside the world | the export-removed error |
| comp_out | a map from component name to its declared :out (or none) | the output-type-changed error |
| edges | the in→out wiring between components | computed, 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 made | caught? | why not |
|---|---|---|
| you rewrote a component's source body | no | the gate never reads the implementation |
| you switched a component from rust to js | no | language isn't part of the signature |
you changed a component's :deps | no | dependencies aren't compared |
| you rewired the edges between components | no | edges are computed but no rule reads them |
| you changed the schedule | no | scheduling lives outside the signature |
| you removed a component whose output was internal | no | a consumed output was never an export |
| you added a whole new component | no | new things break no old promise |
:out report:string → :out report:json | yes | whole-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.