learn / 07·5 — under wbx · the seam

the one placeTWOtargets differ

The parent lesson promised the same binary everywhere — laptop, CI, and the engine's sandbox. A skeptic is right to doubt it: cross-platform tools rot into if (wasm) forks and a second “lite” build that drifts. This page is the receipt. The whole fork is one trait, five methods, one file — and the compiler picks the side, not a runtime check.

the seam11 min read
A single luminous green seam splitting one vast monolithic machine into two mirror halves, a small engineer standing at the bright joint where the two halves meet — bright, monumental, 1970s sci-fi style

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

the seam /ðə ˈsiːm/ noun

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 deploy reaches docker, krunvm, fly; how publish reaches wrangler and git.

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:

verbwhat it asks forseam methods it touches
query · lint · tanglerun the OQL kernel over a fileread (kernel is linked in)
build · run · libraryask the engine to do workhttp (RCP to the discovered engine)
deploystand up / inspect a containerspawn + read + write
publishpush a built site to a hostspawn + http_url
checkout · checkinmove base64 zips through the enginehttp (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:

methodnativewasm — todaywasm — intended
readstd::fs — ambient OSstd::fs — WASI preopensame (works now)
writestd::fs — ambient OSstd::fs — WASI preopensame (works now)
httpreqwest → 127.0.0.1bails — broker pendingDock host import (loopback RCP)
http_urlreqwest → any URLbails — no egresspolicy-gated egress cap
spawnstd::process::Commandbails — native-onlynative-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:

stateverbswhy
works in-sandbox nowquery · tangle · lint · bundle · unbundle · sign · verifyneed only file IO — WASI preopens grant it
bails in-sandboxengine verbs (build · run · library · toolkit)Dock HTTP broker is pending
bails in-sandboxpublish · deployspawn unavailable — native-only orchestration
native-onlydevinteractive, 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.