learn / 02·9 — under nexus · hot swap

change itWHILEit's running

Everywhere else, updating a running system means a deploy train — rebuild, restart, reconnect, pray. The Nexus doesn't, because a name-to-bytes binding is data, not code. An update isn't mutating the running thing — it's adding a new immutable file and repointing a name. In-flight work finishes on the old bytes. Nothing reboots.

hot swap11 min read
A lone engineer on a gantry beside a towering wall of glowing numbered cartridges; one cartridge slides out and a fresh identical-looking one clicks into the same slot while machinery downstream keeps humming, unbroken — bright, monumental, 1970s sci-fi style

the deploy train vs LIVING software

Everywhere else, "update the running system" means a deploy train: rebuild the image, restart the process, reconnect the clients, and pray nothing was mid-flight when the lights went out. For ordinary software that's merely annoying. For living software — agents working continuously, inside an engine you and they both touch — it's lethal twice over.

It's lethal the first way when an agent authors a tool mid-session. It just wrote a command it needs now, three steps into a task. If getting that tool live means a CI run and a restart, the agent's work is gone before the tool arrives. Improvement that can't land without stopping the worker isn't improvement; it's a context switch with extra steps.

It's lethal the second way when a human pushes a fix to a system an agent is actively working in. The old answer was a deploy: rebuild, restart — and drop whatever the agent had in flight. The cowboy answer was worse — tar the new files over SSH, straight onto the box, and clobber the agent's half-written state. One path drops the work; the other overwrites it. The reader has been burned by both, and has learned to fear the exact thing living software needs most: changing a running system without breaking the work inside it.

the DEFINITION

hot swap /hɒt swɒp/ noun

1. a loaded artifact — a toolkit command, a kernel, an agent definition, content — replaced in a running Nexus by repointing a name at new content-addressed bytes; in-flight work finishes on the old bytes, and nothing reboots.

The lineage is Erlang's hot code loading — the famous trick of replacing a module in a live VM without dropping calls. The Nexus gets the same effect one level up: not on VM modules, but on the artifacts the engine loads. Same spirit, different layer — and content addressing does the job BEAM magic did below it.

the binding is the whole TRICK

Here is the entire idea, and it's smaller than you expect. The "live system" is just a tiny map from names to files. When you run a command by name, the engine looks the name up in that map and runs the file it points at. That map — the command registry — is the only thing a swap touches.

The registry stores name → build/commands/<sha>.wasm in a :persistent_term — Erlang's "rare write, fast read" table, so the lookup on every run is a bare memory read with no process in the path. Re-registering a name that already exists doesn't append or version — it replaces the binding in place. The source even labels it, verbatim: HOT-SWAP. And there's an observation hook: current(name) returns the live spec bound to a name, so you can verify a swap actually landed.

The money question is in-flight semantics, so watch one trace carefully. Caller A invokes a command and the engine resolves its path at call time; it now holds the old file. Mid-run, a re-register lands a new sha for the same name. Caller A keeps going — and finishes on the old bytes, because old content-addressed files are never deleted on re-register; a new sha is simply a new file. The next caller, B, invokes the same name and resolves the new sha. Same name, two immutable files, the map moved between them:

sequenceDiagram
  participant A as caller A (mid-flight)
  participant R as registry (:persistent_term)
  participant B as caller B (later)
  A->>R: run("slugify")
  Note over R: resolves at CALL TIME → sha-OLD
  R-->>A: build/commands/9f2c…wasm
  Note over R: re-register "slugify" → sha-NEW lands
  A-->>A: finishes on sha-OLD (old file never deleted)
  B->>R: run("slugify")
  R-->>B: build/commands/4b7e…wasm  (sha-NEW)
  Note over A,B: same name · two immutable files · the map moved
  

That's the confidence claim, exactly: you can change a live system an agent depends on without breaking its work, because a call resolves the file when it starts and keeps that file to the end. Resolution happens at call time, not hold time — so a swap is invisible to anything already running, and live for everything that starts after.

why the swap is SAFE

The reason "repoint a name" doesn't corrupt anything is that the files it points at are immutable and self-naming. Content addressing is the load-bearing property: hash the bytes with sha256, write build/commands/<sha>.wasm — and only if that path is absent. This is idempotent. The same source produces the same bytes, the same hash, the same path; re-registering identical source is a no-op write that lands on the same file. Different source is a different hash, which means a different file — so a swap can never overwrite the bytes another call is reading.

content addressing — the swap's safety floor
sourcebytes from a build — the same input always yields the same bytes
sha256hash the bytes — the hash is the filename
<sha>.wasmwritten only if absent — old files never deleted
repointthe registry binds name → this path — replace in place
on bootreload_persisted re-registers from registry.json — no rebuild
the filename is the proof — so a tampered byte can't masquerade as the trusted one

That last sentence closes a real attack window. The filename being the sha256 of the trusted bytes means tampering is detectable, and the check runs at run time, not just at register time — the registry re-hashes the file when it loads it and refuses a mismatch. So even if something altered the bytes on disk between registration and use (the classic TOCTOU race), the swap fails closed rather than running poisoned code. Registration is fenced too: the engine refuses any wasm path that doesn't canonicalize strictly inside build/commands/, so a registration can't point the map at a wasm planted somewhere else.

And the swaps survive a reboot without rebuilding anything. A tiny manifest, build/commands/registry.json, records name → addressed path plus mode. On boot, reload_persisted re-registers every entry from that manifest — no rebuild, no fetch — skipping reserved or malformed names and any artifact that's gone missing. The content-addressed store is the cache; the manifest is the index into it.

swap by SELF-AUTHORING

One swap primitive, three ways to drive it. The first is the in-session swap: an agent re-registering a command it just rebuilt, in seconds. The whole cycle — write the source, build it, content-address it, register it — runs entirely inside the wasm sandbox; even the compiler is wasm. Six languages are inline-buildable: rust, c, zig, js, ts, go. And the built command is capped by the Instance's Policy profile — self-authoring can never widen the granted capability set. An agent can write itself a sharper tool; it can't write itself a bigger blast radius.

You can drive it by hand to feel it. Build a command from a file:

$ wb toolkit build-inline slugify rust slugify.rs

built + registered command `slugify` (rust) → build/commands/9f2c…81d4.wasm
run it via the Dock: run-command slugify

Now the swap. Edit slugify.rs, re-run the same command. The build produces different bytes, so a different sha — say build/commands/4b7e…0a92.wasm — and the registry repoints slugify at it. Ask the engine what's bound now and CommandRegistry.current("slugify") shows the new path. The old 9f2c…81d4.wasm is still on disk: a call that was mid-flight when you swapped finished on it, exactly as the trace above promised. Two immutable files, one name, the map moved. (Try the same trick on a built-in like jq and you get {:error, :reserved_name} — more on that under the guardrails.)

A command an agent builds mid-session lives in the session by default. The lifecycle ladder is session → workspace → registry: promote materializes an inline command as a real toolkit directory — a manifest, the source, a skill stub — persisting the source, not just the bytes, then packing and installing it like any other promoted toolkit. The swap gets you fast; promotion gets you durable.

swap by PUSH

The second vehicle is a git push that goes live within one tick. Turn on WB_GITOPS=1 and, at the top of every keeper tickbefore the agent runs — the engine reconciles the tenant repo: a push to GitHub becomes live within one tick, tracked and versioned. The critical word is integrate, not overwrite. The reconcile snapshots any dirty agent work first, fetches origin, then does a real git merge --no-edit. It never blows away the agent's in-progress state to take the upstream change.

Walk one push end to end. A human fixes the SEO skill and runs git push origin main, touching skills/seo.org. On the next tick, the runtime snapshots dirty work as wip: snapshot before reconcile, fetches, merges, and returns {:ok, {:applied, %{commits: 1, files: ["skills/seo.org"]}}}; the log reads Keeper: GitOps — pulled 1 upstream commit(s); 1 file(s): skills/seo.org. The very next agent run reads the new skill. Here is that whole flow, including the branch nobody wants but everybody needs:

flowchart TD
  push["human: git push (skills/seo.org)"] --> tick["top of next keeper tick"]
  tick --> snap["snapshot dirty agent work — wip commit"]
  snap --> fetch["fetch origin"]
  fetch --> merge{"git merge --no-edit"}
  merge -- "clean" --> apply["{:ok, {:applied, …}}"]
  apply --> pub["publish content + deploy dist"]
  pub --> live["live · next agent run reads it"]
  merge -- "same file both sides" --> abort["merge --abort · {:conflict, [files]}"]
  abort --> human["reported · rolled back · left for a human"]
  style push fill:#aee5c2,stroke:#121316,stroke-width:2.5px
  style live fill:#13d943,stroke:#121316,stroke-width:2.5px
  style abort fill:#f3c5a3,stroke:#121316
  

Two things make that merge clean in practice. First, the CODE/DATA path split: by convention, CODE — the app's src/, the agent def, design.org, skills/ — is what humans and CI push, while DATA — content/, blog/, the board, rem/ — is what the agent commits. Disjoint paths mean the human's change and the agent's commits almost never touch the same file, so the replay is clean. Second, when they do collide — say the agent also edited skills/seo.org — the merge aborts, rolls back, and returns {:conflict, ["skills/seo.org"]}. A real same-file divergence is reported and left for a human; it is never silently overwritten. The agent's data commits in content/ merge cleanly regardless, because of that very split.

And "live" here is literal and atomic. The runtime publishes inside the commit path, not as a separate step — the lander once shipped blogs that were committed but served a 404 because the run died between commit and a later publish, so the commit and the publish became one operation. On merge, the engine republishes content/** and blog/** (pure file ops, no shell, path-contained, junk filtered) and ships a built app from dist/ to the site root. This page owns the swap semantics; the gitops lesson owns the tenant-repo plumbing underneath it.

the zero-mechanism SWAP

The third vehicle has no mechanism at all, which is the point. The keeper re-reads the agent definition file every tickFile.read!(path), fresh, inside the tick. There is no cache to invalidate. The def is parsed fresh per run — its :ID:, :MODEL:, :TOOLKITS:, and the system prompt under its heading — and the toolkit index is injected per run from what the def declares. So editing the .org def, or a git pull replacing it, is live on the next tick with nothing restarting. "Deploying" a new agent personality is saving a file.

That gives the page its boundary map — the small table the reader will screenshot. When does a change land, and by what mechanism?

artifactthe swap landsmechanism
toolkit command (wasm)next callregistry repoint — in-flight safe
agent def / skill (.org)next tickre-read per run — no cache
content / blogon commitcommit ⇒ publish, atomic

Three artifacts, three latencies, one idea: the live thing is a pointer, and the pointer is cheap to move. A command swaps the instant the next call resolves it; a def swaps the instant the next tick reads it; content swaps the instant the commit publishes. Nothing in that table involves a restart, because nothing in it is the engine.

the GUARDRAILS

Depth rung — skippable. Casual swapping is only safe because a handful of rules make the dangerous swaps impossible rather than discouraged. Four of them carry the weight.

  • Built-ins are unshadowable. Trusted built-in names — jq, grep, upper — are reserved, and the registry merges built-ins last at lookup. So even a poisoned dynamic key bound to jq loses: a swap can never replace a trusted built-in. That's the {:error, :reserved_name} you saw earlier.
  • A hard cap, for a real reason. The dynamic registry tops out at 4096 entries. Not arbitrary: every :persistent_term.put triggers a global garbage collection, so an unbounded registration storm is a denial-of- service. The cap turns "swap freely" into "swap freely, within a ceiling that can't be weaponized."
  • Name hygiene. Names must match a strict character set; empty or exotic names are refused at the door, before they can become a lookup surprise.
  • Provenance gates the swap-in. A third-party toolkit with invalid provenance never builds — the engine refuses it outright and tells the author to wb toolkit sign it first. And trust posture drives the isolation tier at run time: a third-party command is routed to a separate BEAM VM, with graceful fallback to local. You can swap in someone else's code, but only signed, and it runs further from the core than your own.

None of these slow down the common case — your own signed tools, swapping in seconds. They exist so that the freedom to swap can't be turned against the engine that grants it.

the same pattern ELSEWHERE

Depth rung — skippable. Once you see "name → content-addressed bytes, replace in place," you start seeing it everywhere in the engine, because it's one idea reused, not three coincidences.

The pallet — the prebuilt WASI artifacts — re-seeds idempotently at the registry level: re-seeding replaces the binding in place, the same hot-swap, with sha-pinned artifacts fetched once into the content-addressed store. The KernelRegistry is the kernel-shaped twin of the command registry in forty-five lines: name → content-addressed bytes-to-bytes kernel wasm, the same :persistent_term pattern, the same 4096 ceiling, the same Map.put that replaces in place. Commands and kernels swap by the identical move.

And the autopoet rides this as its entire job. It's the agent whose work is implementing capability issues filed by other agents — by editing the declarative config layer (toolkits, skills, defs, the capability registry), never native code. Its edits land as org files, build through the same gates, and swap in live. The autopoet is what hot-swap looks like as a standing practice instead of a one-off.

where it STOPS

Honesty section. The most important limit is the most reassuring one: the host layer does not hot-swap. Engine code — the .ex under runtime/host/ — ships via the CI-built runtime image. That's a redeploy, not a swap. This isn't a gap; it's the design's spine. Loaded artifacts can be edited freely because the engine that loads them is fixed beneath them — agent self-modification is safe precisely because the agent can only ever touch the loaded layer, never the floor it stands on.

The rest of the boundary, stated plainly:

changelandshow
toolkit command (wasm)next callregistry repoint, in-flight safe
agent def / skill (.org)next tickre-read per run
content / blogon commitcommit ⇒ publish, atomic
app dist/on pulldeploy_app → site root
app src/ needing viteafter CI / committed distthe build seam — honest gap
host engine (.ex)redeployCI runtime image, never hot
wbx itselfnext invocationwbx upgrade binary rename

Read the bottom four rows together — they're where a push won't just go live, and knowing them saves you the confusion of pushing the wrong kind of change and wondering why nothing happened. The build seam is the project's own honestly-named gap: a pull integrates the repo, but an app that needs vite build can't be built on the BEAM box, so app src/ rides either a committed dist/ or CI. GitOps is live today for the build-free surface — the agent def, design.org, skills/, and content/.

Two more honest notes. The reconcile trigger is a poll at each keeper tick, not a webhook — a dedicated reconcile endpoint is explicitly the next step, not a shipped one. And the toolkit rebuild is a verb, not a daemon: a git pull republishes content and deploys a committed dist/, but I found no surface that automatically rebuilds a compiled command when a pull changes its source — swapping in new compiled bytes still takes an explicit build-and-register call or an agent action.

For contrast, hold this up against the one thing nearby that isn't a registry repoint: wbx upgrade swaps the CLI's own native binary by downloading a new one and renaming it into place — a host-binary swap that takes effect on the next invocation, not a live repoint of a running map. Same word, different layer. The registry swap is live and in-flight-safe; the binary swap waits for you to run the tool again.

questions people actually ASK

Does an in-flight call see the new version?

No. A call resolves its file at call time and keeps that file to the end. Old content-addressed bytes are never deleted on re-register, so anything already running finishes on what it started with. New calls — and only new calls — get the new artifact.

Can a swap replace jq or grep?

No. Built-in names are reserved, and the registry merges built-ins last at lookup, so even a dynamic key bound to jq loses. Trying it returns {:error, :reserved_name}. A trusted built-in is unshadowable by construction, not by policy.

What survives a reboot?

The content-addressed store plus a tiny registry.json manifest of name → addressed path. On boot, reload_persisted re-registers every entry — no rebuild, no fetch. Your swaps come back as bindings without rebuilding any bytes.

Can someone swap a tampered artifact in?

The filename is the sha256 of the trusted bytes, and the registry re-hashes the file at run time and refuses a mismatch — so a byte altered after registration fails closed instead of running. Registration also refuses any path that doesn't canonicalize inside build/commands/.

Why didn't my pushed Svelte change go live?

The build seam. A pull integrates the repo and deploys a committed dist/, but it can't run vite build on the BEAM box. Commit the built dist/, or let CI build it — then the deploy lands on pull like everything else.

Is this just Erlang hot code loading?

Same spirit, different layer. Erlang swaps modules inside the VM; the Nexus swaps the artifacts the engine loads — commands, kernels, defs, content — using content addressing where BEAM used VM machinery. The engine's own code, notably, does not hot-swap; that's a redeploy, by design.

keep GOING

Hot-swap is the artifact-level corollary of the engine you own — start with the parent, then follow the vehicles.

Going deeper on the parts: toolkits are what gets swapped, lanes is the compiler-in-sandbox that builds them, promotion is the session→workspace→registry ladder, and upgrades covers how a live plan swaps under its own subtyping discipline.