combining systems is a MIGRATION
Composition is the thing software is worst at. Two working projects, one obvious move — put them together — and the industry's answers are all surgery: git submodules, a package registry and a version matrix, or the full monorepo migration with its weeks of build-system archaeology. And un-combining is worse. Ask anyone who has tried to extract a service from a codebase it grew into: nothing comes out clean, because nothing was ever a unit in the first place.
The parent lesson made a simple claim: one workbook is one file — the app, its disk, its plans, all of it. Which leaves exactly the question a person with ten workbooks actually has. Do these things compose? Or is the ecosystem's endgame a folder of beautiful silos, each one whole and none of them able to join?
This page is the answer, and it has three beats. Combining is a paste. The paste compiles. And the prune — the un-combining — is gated, so taking systems apart is the one move that finally tells you who breaks.
the DEFINITION
1. the property that a workbook is a tree whose every subtree is itself a complete, compilable workbook — so combining systems is grafting a subtree in, splitting them is pruning one out, and the prune is gated.
The unit underneath is the subtree, and the kernel defines it in
one line: a headline plus everything deeper, until the level pops back.
That boundary — subtree_end() in the kernel — is what the
compiler skips over when it consumes a nested workflow, and it's the same
boundary your editor moves when you cut and paste a branch of the outline.
Where the subtree ends is where the unit ends. Everything else on this
page is consequences.
the paste that COMPILES
Here's the experiment, run for real against the kernel. Take a complete
workflow — the Pipeline you'll meet fully in the next
section — and graft it into a different workbook: demote every headline
two levels (* becomes ***) and paste the whole
subtree under someone's prose, two layers deep in their notes file. Then
tangle both files.
The compiled world is identical. Same components, same edges, same exports, same sub-worlds — byte for byte. Not similar. The same.
flowchart LR p["pipeline.org
* Pipeline :workflow:
(its own file, top level)"] g["company.org
* Company notes
** Some prose section
*** Pipeline :workflow:"] w[["one world JSON
byte-identical either way"]] p -. "demote two levels, paste the subtree" .-> g p -- "wb tangle" --> w g -- "wb tangle" --> w style p fill:#ffffff,stroke:#121316 style g fill:#ffffff,stroke:#121316 style w fill:#13d943,stroke:#121316,stroke-width:2.5px
The reason is small and structural: every comparison in the compiler is
relative. A child is anything deeper than its parent — deeper
than, never depth three. No absolute level appears
anywhere in the compile, and top-level worlds are found wherever they
stand: any :workflow: headline not already consumed by a
parent becomes a world, whether it sits at level one or level five. So
every workbook is graft-ready by construction. Nobody designed an import
mechanism; the grammar's levels being relative is the import
mechanism.
And notice what the move is made of. There is no graft verb in the CLI —
no wb merge, no manifest to update, no registry round-trip.
Grafting is a text edit: cut the subtree, fix the levels, paste,
re-tangle. Which means an agent can do it with the one tool agents
actually have, and you can review it the way you review any edit — as a
diff.
one tree, one DAG
The graft would be a parlor trick if the paste were only storage. It isn't — the nested tree compiles, nested. The workflows lesson ended with headline-nesting as a planning convenience (plans hold plans) and a single-level compile. This is where those two ideas meet. Here's the whole worked example:
* Pipeline :workflow:
** Ingest :component:
#+begin_src rust :out raw:bytes
#+end_src
** Transform :workflow:
*** Clean :component:
#+begin_src js :in raw:bytes :out clean:json
#+end_src
*** Score :component:
#+begin_src rust :in clean:json :out score:f64
#+end_src
** Publish :component:
#+begin_src go :in raw:bytes
#+end_src
One workflow, three tasks — and one of the tasks is a workflow. The
compiler, build_world(), is literally recursive: walking the
children, a :component: child becomes a leaf; a
:workflow: child triggers the same function on the subtree,
then skips past it. wb tangle pipeline.org emits one plan
(trimmed, real output):
{"worlds":[{"name":"Pipeline","exports":[],"imports":[],
"edges":[{"from":"Ingest","to":"Publish"}],
"workflows":[{"name":"Transform","exports":["score:f64"],
"edges":[{"from":"Clean","to":"Score"}],"workflows":[]}]}]}
Read the shape before the values: the sub-world is not a stub or a
reference — it's a full world, the same JSON shape at every depth,
with its own components, edges, and exports, and room for sub-worlds of
its own. Each level can even carry its own clock — a
:SCHEDULE: cron property or a native
SCHEDULED: timestamp on any workflow headline, so a monthly
parent can hold an hourly child.
flowchart TD
subgraph P["Pipeline :workflow: — exports nothing"]
I["Ingest
:component: · out raw:bytes"]
subgraph T["Transform :workflow: — exports score:f64"]
C["Clean
:component:"] -- "clean:json" --> S["Score
:component:"]
end
Pub["Publish
:component:"]
I -- "raw:bytes" --> Pub
I -. "raw:bytes — declared, not piped yet" .-> C
end
style P fill:#fbfaf6,stroke:#121316
style T fill:#e8f3fa,stroke:#121316
style I fill:#ffffff,stroke:#121316
style C fill:#ffffff,stroke:#121316
style S fill:#ffffff,stroke:#121316
style Pub fill:#ffffff,stroke:#121316
The org tree and the execution DAG are the same object at different
zoom levels — zoom out and it's an outline a person edits; zoom in and
it's a typed plan an engine runs. And you can run this yourself, right
now, with nothing docked: wb tangle, wb query,
and wb lint are local kernel verbs — the same ~550-line pure
Rust kernel, built into the CLI, also served over HTTP and
WebSocket. One kernel, every surface, no runtime required to see your own
plan.
what a world shows OUTSIDE
depth rung · skippable — how the kernel computes each world's typed surface
Every world in that JSON — at every depth — gets a computed signature,
and the rules are short enough to state exactly. Edges: one
component's :out name matches another's :in.
Imports: the sorted union of every component's :uses
capability strings — names like workbook:vfs/query,
workbook:llm/complete, workbook:net/email, the
same capabilities toolkits fulfill.
Exports: the outputs nobody inside the world consumed.
That last rule is the elegant one — consumption is encapsulation. Trace it
through the Pipeline:
| component | :in | :out | consumed by | so the surface says |
|---|---|---|---|---|
| Ingest | — | raw:bytes | Publish | raw:bytes vanishes — internal |
| Transform / Clean | raw:bytes | clean:json | Score | clean:json vanishes — internal |
| Transform / Score | clean:json | score:f64 | nobody | Transform exports score:f64 |
| Publish | raw:bytes | — | — | a sink — exports nothing |
That's why Pipeline exports [] while its own
sub-world exports score:f64 — the parent's raw bytes were
eaten by Publish, the child's score was eaten by no one. The names are
typed by convention, name:type — raw:bytes,
clean:json, score:f64 — and the imports column
is empty here only because no component declares :uses; add
:uses workbook:net/email to one block and the world's surface
grows that import.
If imports-exports-typed-names sounds familiar, it should: the plan is
deliberately shaped like a WebAssembly WIT world. The kernel takes
its own medicine — it ships as a WIT-typed component,
workbooks:oql, exporting tangle-plan and
check-upgrade as plain string-to-string functions with no
host imports at all. The thing that computes the surfaces has one.
grow free, shrink GATED
Pasting in is half of composition. The other half — the half nobody
ships — is taking things back apart without silently breaking whoever
depended on the part you removed. That's check_upgrade: diff
the old plan's signatures against the new plan's, in the style of
interface subtyping. The rules, exactly as the kernel applies them:
| change between old and new | verdict | why |
|---|---|---|
| exports grow | safe | new consumers become possible; existing ones are untouched |
| export removed | error | someone downstream may be reading it |
| new import required | warn | a new capability ask — flagged, not forbidden; granting is the engine's call |
| component output type changed | error | every consumer parses that type |
| workflow removed | error | pruning a subtree takes its exports with it |
Here's a real run — a one-component world whose new version starts emailing and changes its output from string to json. Three diagnostics, each named and scoped:
[{"level":"error","message":"export `report:string` removed (breaking)","scope":"Report"},
{"level":"warn","message":"new capability `workbook:net/email` now required","scope":"Report"},
{"level":"error","message":"component `Build` output type changed (breaking)","scope":"Report"}]
Now the nesting payoff. Prune the Transform subtree out of
the Pipeline and gate old against new — two errors, two scopes:
[{"level":"error","message":"export `score:f64` removed (breaking)","scope":"Pipeline"},
{"level":"error","message":"workflow removed (breaking)","scope":"Transform"}]
Run the same gate the other direction — old is the flat Pipeline, new
is the nested one — and the answer is []. Empty. Growing
the tree is always safe; pruning is gated. That asymmetry is the
page's thesis in one diff: graft freely, and when you finally split the
product back into two workbooks, the gate hands you the exact list of
promises you're about to break — named, scoped, before anything deploys.
One subtlety is worth being precise about, because it explains why
Pipeline appears in that error at all. The gate computes each
workflow's signature over the flat set of all component
descendants — so a parent's upgrade-signature includes its sub-worlds'
exports, which is exactly why Pipeline is charged with losing
score:f64 when Transform goes. The compiled plan you saw
earlier scopes each surface to direct children instead. Two lenses on the
same tree: scoped for execution, flat for the safety diff. The
flat lens is also what lets a parent-level output reach a sub-world's
component in the gate's accounting — though, as the
seams note, that cross-boundary edge is neither piped nor cleanly
validated yet.
And the honest edge, stated here so the next paragraph can't oversell it: the gate is keyed by workflow title. Rename a workflow and the diff reads remove-plus-add — breaking. Today the gate ships as a kernel export with a runtime wrapper; wiring it into the deploy path is the runtime's job, not yet done. More in the seams.
how the nest EXECUTES
depth rung · skippable — what the engine does with a nested plan
A nested plan runs as a nested run. The engine's recursion mirrors the compiler's: running a world executes its components in topological waves — everything whose producers are done runs together, up to eight steps in parallel, ten minutes each — and recurses into each sub-world the same way. A producer's stdout is piped into its consumer's stdin along the computed edges, and the result is a nested run record: each level reports its workflow, schedule, exports, tasks, and its sub-workflows' records inside it.
sequenceDiagram participant E as the engine participant P as Pipeline — the world participant T as Transform — the sub-world E->>P: run_world(input) Note over P: wave 1 — Ingest runs Note over P: wave 2 — Publish runs,
Ingest's stdout on its stdin P->>T: recurse — run_world(original input) Note over T: wave 1 — Clean · wave 2 — Score,
clean:json piped between them T-->>P: sub-record — exports score:f64 P-->>E: one nested run record
Two details make the recursion more than bookkeeping. First, an
agent-language component is just another step: its source
block is the system prompt, and the engine runs it as a bounded agent —
six steps, then done — with the wave's piped input as its input. A
sub-world can fan out sub-agents inside the same DAG, no second
orchestrator. Second, the tree's properties survive the recursion:
:persist markers are collected by walking worlds and their
nested workflows all the way down, and a build compiles every component in
the whole tree, however deep the graft put it.
One boundary, flagged now and detailed in the seams: when the engine recurses into a sub-world, it hands it the workflow's original input — not a sibling's piped output. The dotted edge in the earlier diagram is declared and validated, not yet piped.
the nesting that ISN'T
There's a second thing people mean by workbooks-inside-workbooks: not trees in a file, but runtimes — a running, sandboxed program calling another program. And here the design does the opposite of everything above, on purpose. Declarative nesting recurses without bound, because a tree is just a tree. Runtime nesting never truly recurses at all.
When a sandboxed guest execs a command, the host does not boot a sandbox inside the sandbox — no wasm-in-wasm, no OS shell, ever. The host brokers the call: it runs the target as a registered wasm command in its own fresh, isolated instance — a sibling, not a child. However deep the call chain reads in your code, the actual shape is a flat forest of peer sandboxes, with the host between every pair.
sequenceDiagram participant G as a running guest — depth 0 participant H as the host — the exec broker participant S as a fresh sibling instance — depth 1 G->>H: host_exec — name, argv, stdin (structural, length-prefixed) Note over H: the cond ladder — revoked? rate-limited?
commands cap granted? depth within bounds?
allow-listed? registered? H->>S: run the registered wasm command — its own instance S-->>H: stdout — capped at 8 MiB H-->>G: result · every denial audit-logged
The ladder is default-deny: a guest can exec nothing unless the
commands capability was granted to that instance —
capabilities flow down by explicit grant, never by inheritance. The argv
is a structural list, not a string, so ; rm -rf / arrives as
a literal argument to a wasm program, not a shell incantation. And two
constants bound the blast radius, straight from the broker:
@default_max_output 8 * 1024 * 1024 # 8 MiB output cap
@max_depth 8 # exec-recursion bomb stop
# …
depth > @max_depth ->
deny(name, "nesting depth exceeded"); {:error, :max_depth}
The depth counter exists for one named failure mode: a guest exec'ing
guests that exec guests — the recursion bomb. The test suite checks the
cliff directly: ask the broker to run seq 1 at depth 99 and
the answer is {:error, :max_depth} before any sandbox spins
up. Full honesty about today: current lanes launch commands as plain
subprocesses without the exec import wired in, so depth above zero doesn't
yet arise in practice — the bound is defense-in-depth for the lane that
will use it, not a description of live eight-deep chains.
So the dek's last clause, earned: in the file, nesting is real and unbounded, because text costs nothing. In the runtime, nesting is an illusion the host maintains — siblings in a flat forest, each in its own sandbox, counted to eight. The tree recurses; the sandbox never does.
where the SEAMS are
Honesty section — boundaries, not apologies. Each of these is a fact about today's code, and each marks where the composition story currently hands off to the runtime's roadmap.
Sub-worlds get the original input. The recursion passes a
sub-world the same input the parent run started with — not a sibling
component's piped output. Cross-boundary wiring (Ingest's
raw:bytes into Transform's Clean) is declared in the grammar,
and the gate's flat signatures account for it — but the validator also
checks each sub-world on its own, where raw:bytes has no
producer, so the declaration draws a diagnostic today. And the executor
doesn't pipe across the world boundary either. The dotted edge is a
contract waiting for both its check and its plumbing.
The gate is a kernel export, not yet a tollbooth.
check_upgrade ships in the kernel, has a runtime wrapper, and
runs in the demos — but no deploy path calls it yet. The kernel can
refuse a breaking diff; making every push stop at that refusal is wiring
the runtime still owes. Nothing on this page claims your redeploys are
gated today.
Renames read as removals. The gate keys workflows by title, so renaming one is remove-plus-add — breaking — and two same-titled workflows at different depths would collide in the signature map. Titles are identity; treat them that way.
One producer per name. Within a world, edge computation maps
each output name to a single producer — if two components both declare
:out raw:bytes, the last writer wins. Name your outputs like
you name your exports, because that's what they are.
questions people actually ASK
Is there a depth limit on nesting workbooks?
No — the compile is unbounded recursion over a tree, and a tree is just text. The famous limit of eight belongs to a different axis entirely: it caps runtime exec chains in the sandbox broker, not headline depth in your file. Nest your plans as deep as your outline goes.
How do I split a workbook back out?
The graft, reversed: cut the subtree, promote its levels, save it as its own file — both halves compile, because every subtree was already a complete workbook. Then run the gate on the half you took it from, and it names exactly which exports disappeared and who is scoped to the breakage. Un-combining with a manifest of consequences is the entire point.
Do sub-workflows see the parent's data?
Declared, yes — a sub-world's component can name a parent-level
output as its :in, and the gate's flat signatures account
for it. Piped, not yet — at run time the sub-world receives the
workflow's original input, and the validator still flags the
cross-boundary input as dangling inside the sub-world. The contract is
in the file ahead of the plumbing, which is the order this ecosystem
prefers.
Is a nested workbook still one file?
Yes. Nesting happens inside the org layer — deeper headlines, same file. Nothing on this page adds a second artifact. Shipping a system as several files is a different story with its own mechanics, and its own lesson when it lands.
Why is a new import only a warning, when removals are errors?
Because the two failures have different owners. A removed export
breaks consumers no matter who approves it — the kernel can say so with
certainty. A new :uses is an ask: capabilities are
granted at dock time, per instance, by the engine
and whoever runs it. The gate's job is to make the ask visible before
it's granted — flagging, not forbidding.
keep GOING
Nesting is the composition story — four neighbors carry the rest.