the fork you always end UP with
Start a tool that has to run in two places and the rot is predictable.
First a runtime check creeps in — if (isWasm) { … } else { … } —
and then it breeds, because the second place keeps surprising you. Then a
mock layer, because the test suite can't reach the real platform. Then,
quietly, a second build target with its own feature flags and its own bugs,
and within a year you maintain two programs wearing one name.
So “the same binary everywhere” reads as marketing. The honest engineer's question is the right one: how can one source truthfully serve a laptop with ambient operating-system authority and a sandbox that has none — without that split metastasizing into the whole codebase? The answer isn't discipline applied everywhere. It's a boundary drawn in exactly one place, and a rule that the difference is never allowed to leak past it.
the DEFINITION
1. the single trait — Io, five
capability methods — where the wbx CLI's two
compile targets are permitted to differ. Everything above it — arg
parsing, kernel calls, JSON envelopes — is target-blind; below it,
native uses std and reqwest, wasm uses host
imports brokered by the runtime Dock.
The thesis is written at the top of the file itself, verbatim: “The capability seam. The ONLY place native and wasm targets differ. Everything above this … is target-agnostic. Below it, native uses std/reqwest; wasm uses host imports brokered by the runtime Dock — capabilities granted, not assumed.” That last clause is the whole design in four words. You don't abstract the platform. You abstract the capabilities — the short list of things the CLI ever asks the outside world for.
five questions, one VOCABULARY
The entire vocabulary of “things the CLI asks the world for”
is five methods. Two are HTTP, because the CLI talks to an
engine for everything it doesn't compute itself; the
other three are file read, file write, and process spawn. That's it —
nothing else crosses the boundary between wbx and the machine
under it.
- http — an RCP request to the
discovered engine. The argument is path-only —
{method, path, body}, no host. Where the engine lives isn't the caller's business; discovery supplies the host. - http_url — the second HTTP, for an arbitrary full URL: a self-hosted publish target, a remote engine. Same verb family, but the caller names the destination.
- read — bytes at a path.
- write — bytes to a path.
- spawn — run a program, return its stdout. This is how
wbx deployreachesdocker,krunvm,fly; howpublishreacheswranglerandgit.
Every verb the CLI exposes is built out of those five and nothing more. The mapping is worth seeing directly — which user-facing verb touches which capability:
| verb | what it asks for | seam methods it touches |
|---|---|---|
query · lint · tangle | run the OQL kernel over a file | read (kernel is linked in) |
build · run · library | ask the engine to do work | http (RCP to the discovered engine) |
deploy | stand up / inspect a container | spawn + read + write |
publish | push a built site to a host | spawn + http_url |
checkout · checkin | move base64 zips through the engine | http (RCP) |
Read the table's verdict in one line: kernel verbs bottom out in
read, engine verbs in http, and the heavy
orchestration verbs — deploy and publish — are the only ones that reach for
spawn. There is no sixth column, because there is no sixth
capability.
the compiler is the SWITCH
Now the move that keeps the fork from spreading. Which implementation of
the trait you get is decided by platform() — and it is a
compile-time decision, keyed on the target triple, not a runtime
detection and not a feature flag:
pub trait Io {
fn http(&self, req: HttpReq) -> Result<String>; // RCP to the discovered engine
fn http_url(&self, method: &str, url: &str, body: Option<&[u8]>, bearer: Option<&str>) -> Result<String>;
fn read(&self, path: &str) -> Result<Vec<u8>>;
fn write(&self, path: &str, bytes: &[u8]) -> Result<()>;
fn spawn(&self, program: &str, args: &[&str]) -> Result<String>; // docker / krunvm / fly / wrangler / git
}
pub fn platform() -> Box<dyn Io> {
#[cfg(not(target_arch = "wasm32"))] { Box::new(native::Native) }
#[cfg(target_arch = "wasm32")] { Box::new(wasm::Wasm) }
}
The consequence is the entire point of the lesson: every line above the
seam holds an &dyn Io and cannot tell which target it
is on. rcp::call is one function — it just invokes
io.http(…). The CLI never reimplements the engine, never
branches on platform, never asks “am I in a sandbox?” The
compiler already answered before the program ran.
The proof this is real and not aspirational is in the dependency list.
reqwest — the HTTP client — is a target-gated dependency:
# cli/Cargo.toml
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
The wasm build never compiles reqwest — it cannot, by
construction, even contain a socket client. A runtime feature toggle would
still ship the client and gate it; here the client simply isn't in the
binary. Two build commands produce the two artifacts from one source:
cargo build --release → wbx (native) cargo build --release --target wasm32-wasip1 → wbx.wasm
The crate's own SPEC says it plainly: “One Rust crate, two build targets. We do not maintain a separate wasm version. It's the same code, a second target.” The shape of that fork, drawn out:
flowchart TD src["one source — cli/src/**
arg parsing · kernel calls · command logic — target-blind"] src --> cargo{"cargo build
--target"} cargo -- "native triple" --> nat["Native impl
std::fs · std::process · reqwest"] cargo -- "wasm32-wasip1" --> wasm["Wasm impl
WASI preopens · Dock host imports"] nat --> n1["ambient OS authority"] wasm --> w1["capabilities granted, not assumed"] style src fill:#ffffff,stroke:#121316 style cargo fill:#13d943,stroke:#121316,stroke-width:2.5px style nat fill:#d9dbd3,stroke:#121316 style wasm fill:#d9dbd3,stroke:#121316 style n1 fill:#fbfaf6,stroke:#121316 style w1 fill:#fbfaf6,stroke:#121316
Read it top to bottom: one source flows into cargo, which
forks on the target triple alone. The native triple yields an
implementation built on std and reqwest with the
operating system's ambient authority; the wasm32-wasip1 triple
yields one built on WASI preopens and Dock host imports, where nothing is
assumed and everything is granted. The fork has exactly one node, and it's
the compiler.
Why Rust and not the project's Elixir escript? Because the binary must run in three places: a laptop or CI box with no Erlang, inside the runtime, and inside the wasm sandbox. An escript fails the first and the third — it drags the whole BEAM and can't compile to wasm. The language choice is the seam choice, made once.
finding the engine: RUNTIME.json
The http method dials “the discovered engine.”
Here is the part that surprises people: where the engine lives is
itself a capability — and the capability is a small JSON file. The runtime
writes it; the CLI reads it; the path is canon on both sides. This is the
centerpiece of the seam, because it's where a Rust function and an Elixir
module agree on a contract byte-for-byte without sharing a line of code.
The Elixir side, Workbooks.Desktop.write_discovery!, writes
a payload and locks it down:
{ "port": 4000, "token": "Vt3…24-random-bytes…", "pid": 17, "scheme": "http", "mode": "container" }
→ ~/Library/Application Support/sh.workbooks/disco/runtime.json (chmod 0600)
The token is per-boot — 24 random bytes, base64url,
memoized for the life of the process — and the file is chmod
0600 because that token is a local credential: it must stay
user-only. The mode field is "container" when the
runtime sees WB_DESKTOP_DIR == "/disco" (the tray owns its
lifecycle) and "raw" for a hand-launched dev runtime the tray
must never kill. The Rust side reads scheme, port,
and token; it ignores pid and mode,
which exist for the desktop tray.
The loop closes when you run wbx deploy local. The native
spawn launches the container with a bind-mount placed so the
runtime's discovery file lands exactly where the native seam will read it
back:
sequenceDiagram participant U as you — wbx deploy local participant C as the container participant R as the runtime (Elixir) participant W as native wbx (later) U->>C: run with -e WB_DESKTOP_DIR=/disco
-v ~/…/sh.workbooks/disco:/disco C->>R: boot — PORT=4000 R->>R: mint per-boot token (24 rand bytes) R->>U: write /disco/runtime.json — chmod 0600 Note over U,W: the file now sits in the host app-dir W->>W: discovery_path() — read runtime.json W->>R: GET http://127.0.0.1:4000/api/library/local
Authorization: Bearer <token> R->>W: 200 — the library
Follow it as a story: you ask wbx to deploy locally; it
spawns a container whose /disco directory is bind-mounted to
the real app-data folder on your machine. Inside, the runtime boots on port
4000, mints a fresh token, and writes runtime.json at mode
0600. That write lands — through the bind mount — in the host directory the
native CLI reads from. So the next time you run any engine verb,
wbx finds the file, reads scheme, port and token, and dials
127.0.0.1:4000 with a bearer header. The container published
-p <port>:4000, so dialing the guest port on loopback
reaches the engine. Two processes, no shared library, one agreed-upon path —
that's discovery.
One escape hatch sits before discovery: set
WB_ENGINE_URL (and optionally WB_ENGINE_TOKEN)
and the CLI skips the file entirely. The doc comment says why — the
discovery file only knows about local engines; a remote one you
name directly.
the native edge, END to end
depth rung · skippable — one verb traced through the native side
Trace one engine verb on a laptop. You type wbx library.
Above the seam, the verb builds an HttpReq with path
/api/library/local and hands it to rcp::call,
which calls io.http(req). On the native target, that lands in
Native::http, and four things happen in order.
First, the remote override: if WB_ENGINE_URL is set, the
URL is built from it (plus WB_ENGINE_TOKEN if present) and
discovery is skipped. Otherwise, second, discovery runs — read
runtime.json, parse {scheme, port, token}; a
missing port is the only hard parse error, scheme defaults
http, token defaults empty. Third, the URL is assembled as
{scheme}://127.0.0.1:{port}{path} with an
Authorization: Bearer <token> header — the bearer added
only when nonempty. Fourth, a fresh reqwest::blocking::Client
makes the call; only GET/POST/PUT/DELETE are accepted (anything else
bail!s “unsupported method”), a body forces
content-type: application/json, and a non-2xx response becomes
an error carrying the status and body text.
If the discovery file isn't there, the error is canon and user-facing:
wbx: no runtime discovery at …/disco/runtime.json — is the runtime running? (try `wbx deploy local`)
That string matters beyond its words. The exit-code map classifies any
error mentioning runtime.json as code 3 —
“engine unreachable,” marked retryable, with the
hint to start one with wbx deploy local or set
WB_ENGINE_URL. So a missing discovery file isn't a stack
trace; it's a typed, retryable, fix-it-yourself condition an
agent can branch on. In --json mode
the same failure is an envelope:
{"ok":false,"verb":"library","error":{"code":3,"hint":"…","retryable":true}}.
spawn on this side is just std::process::Command:
it returns stdout, and bails with stderr on a nonzero exit. Nothing clever —
the cleverness is that the verbs above never know whether the program ran
natively or whether, on the other target, the request was refused.
the wasm edge: granted, not ASSUMED
Now the same trait, compiled for wasm32-wasip1. The most
surprising fact first: read and write are the
same std::fs code as native. Not a reimplementation, not
a shim — the identical lines. They work in the sandbox because WASI
preopens hand the sandboxed CLI real, capability-scoped file IO. So
std::fs::read means two different things at two ends of the
seam: native, it's ambient OS authority; wasm, it's a granted preopen
capability. Same API, two authorities — and the kernel and assembly verbs
(query, tangle, lint, bundle) run fully
in-sandbox as a result, because all they need from the world is to read a
file.
The HTTP and spawn methods are where the two targets visibly part. They don't pretend; they bail with a message that teaches:
wbx: engine verbs aren't available in the wasm sandbox yet — the Dock HTTP broker is pending; run the native wb wbx: network egress isn't available in the wasm sandbox wbx: process spawn is unavailable in the wasm sandbox (deploy/publish orchestration is native-only)
The first message names the future honestly. In-sandbox HTTP will be brokered by the runtime: the Dock maps the CLI's RCP calls onto the engine it runs inside — loopback, not a socket. Until that Dock export lands, engine verbs report the limitation instead of faking a connection. The full picture, five methods across three columns:
| method | native | wasm — today | wasm — intended |
|---|---|---|---|
read | std::fs — ambient OS | std::fs — WASI preopen | same (works now) |
write | std::fs — ambient OS | std::fs — WASI preopen | same (works now) |
http | reqwest → 127.0.0.1 | bails — broker pending | Dock host import (loopback RCP) |
http_url | reqwest → any URL | bails — no egress | policy-gated egress cap |
spawn | std::process::Command | bails — native-only | native-only (by design) |
The table's verdict: two methods already work identically on both sides
because file IO is a WASI primitive; two are pending the Dock; and one,
spawn, stays native by design — orchestrating containers from
inside a sandbox is not a thing the sandbox should do. The pattern the
pending http will follow already exists today for compiled
commands. Workbooks.RustDock provides env.* host
functions — host_http_get, host_exec, and
filesystem, key-value, secrets, queue, and socket caps — each
policy-gated by profile and presence-gated for Rust, with a
64-request batch ceiling as a denial-of-service floor.
Workbooks.JsDock is the JS analog. When the broker lands, the
wasm http becomes exactly that kind of host import — a
host_http_get-style call the runtime can deny — not a socket
the CLI opens. Capabilities granted, not assumed: the sentence from the top
of the file, made of working code.
the two other cfg SITES
depth rung · skippable — the only two cfg sites outside io.rs
If the seam is the only place targets differ, why does wbx
dev behave differently in the sandbox? Because the seam stays one
file by letting a few verbs declare themselves native at the verb,
rather than leaking cfg blocks across the codebase. There are
exactly two such satellites — verified by grep, the whole fork surface is
three files: io.rs, dev.rs, mode.rs.
wbx dev is native-only. It serves a local preview —
an interactive, long-running verb that polls file mtimes every 300ms and
binds a TcpListener (port 4321, or the first free port up to
4331) to inject a version-poll reload. None of that belongs in a sandbox,
so the wasm build compiles the module but the verb bails:
wbx: `wbx dev` needs the native binary (it serves a local preview); in-sandbox, use `wbx bundle`
mode::pick — the human interactive picker — is the
other. Its wasm implementation returns the default silently, so the
in-sandbox CLI can never block on stdin waiting for a person who isn't
there. Both satellites express the same discipline: a verb that is
inherently interactive or long-running owns its own
cfg at the point of use, and the seam itself stays the single
capability boundary it claims to be.
one path, two LANGUAGES
depth rung · skippable — why the discovery path is duplicated
The discovery path lives twice — once in Elixir, once in Rust — and that duplication is correct, not drift. Two separate processes, in two languages, with no shared library between them, must agree on a single file location. So each computes it, and the doc comments cross-reference to keep them honest. Side by side, they're the same three rules:
// Rust — cli/src/io.rs (doc: "Mirror of Workbooks.Desktop.discovery_path/0")
WBX_DESKTOP_DIR / WB_DESKTOP_DIR override → <dir>/runtime.json
else app_dir()/disco/runtime.json
app_dir(): macOS ~/Library/Application Support/sh.workbooks
else ~/.local/share/sh.workbooks
## Elixir — runtime/host/desktop.ex (Workbooks.Desktop.discovery_path/0)
WB_DESKTOP_DIR override → <dir>/runtime.json
else per-OS sh.workbooks/disco/runtime.json
darwin: ~/Library/Application Support/sh.workbooks/disco
else: ~/.local/share/sh.workbooks/disco
Same override env, same per-OS roots, same filename. The Elixir doc says
it outright: “the path is canon on both sides, so raw-dev and
container runtimes are discovered identically.” The duplication is
the cost of having two processes meet at a file instead of through a shared
binary — and the cross-referencing comments are how the project polices the
drift that duplication invites. Note one detail: every env var in this
lesson has two spellings, because the Rust helper tries WBX_<key>
first and falls back to the legacy WB_<key>.
what bails TODAY
Honesty section. The in-sandbox wbx is partial right now,
and the design choice worth defending is that it fails loudly with the
fix in the message rather than pretending. Here is the real line:
| state | verbs | why |
|---|---|---|
| works in-sandbox now | query · tangle · lint · bundle · unbundle · sign · verify | need only file IO — WASI preopens grant it |
| bails in-sandbox | engine verbs (build · run · library · toolkit) | Dock HTTP broker is pending |
| bails in-sandbox | publish · deploy | spawn unavailable — native-only orchestration |
| native-only | dev | interactive, long-running, binds a socket |
Three honest caveats sit beside that table. The Dock HTTP broker for the
CLI is pending, not shipped — no runtime module references
wbx.wasm today, so in-sandbox docking of the CLI is genuinely
not wired yet. The runtime image bakes the CLI at
/usr/local/bin/wbx so “agents in the container use the
same CLI users do” — but those in-container agents run the
native linux build (the process tier), not wbx.wasm;
the wasm target is today a release asset and the future in-sandbox tier.
And the wbx.wasm artifact ships from CI on every
wbx-v* tag, built with --target wasm32-wasip1
alongside five native targets, into one GitHub Release. The wasm side is
real, shipped, and downloadable — it just doesn't yet have the Dock socket
it will eventually dial through. Telling you that is the contract.
questions people actually ASK
Why a trait object, not feature flags or two crates?
A trait object lets every verb above the seam hold one
&dyn Io and stay target-blind, while the compiler picks
the concrete side at platform(). Feature flags would still
compile both worlds and gate them with conditionals that spread; two
crates would be the “second lite build” this whole design
exists to avoid. One crate, one trait, one compile-time pick.
Why does wasm read/write work but http doesn't?
Because file IO is a WASI primitive and sockets aren't — by design.
WASI preopens give a sandboxed program real, capability-scoped access to
specific directories, so std::fs just works. Outbound
network is deliberately not ambient in WASI; it has to be
brokered by the host. So read/write need
nothing new, and http waits on the Dock broker.
Is the discovery token a secret?
Yes. It's a local credential: 24 random bytes minted per boot, written
to runtime.json at chmod 0600 so only your user
can read it, and required as a bearer header on every engine call. Treat
it like any other key on your machine.
What if I run the engine remotely?
Set WB_ENGINE_URL — and WB_ENGINE_TOKEN if it
needs auth — and the CLI skips discovery entirely and dials that URL. The
runtime.json file only describes local engines; a
remote one you name directly.
Why mirror discovery_path in two languages instead of sharing it?
Because the runtime (Elixir) and the CLI (Rust) are two separate processes with no shared binary. They can only meet at a file, so each computes the same path independently, and the doc comments cross-reference each other to police drift. The duplication is the honest cost of the process boundary.
WBX_ or WB_ for the env vars?
Both. The CLI tries WBX_<key> first and falls back
to the legacy WB_<key>, so every variable in this
lesson — WB_ENGINE_URL, WB_DESKTOP_DIR, and the
rest — has two accepted spellings.
keep GOING
The seam proves the claim its parent makes — and the pieces it touches each have their own lesson.