Intermediate representation (IR)¶
The IR is a pure-data model of a swarm. It lets GenSwarms describe, validate,
mutate, and drive a swarm as plain JSON-shaped data — with no Code.eval, no
shell, and no atom minting from untrusted input. It is also the foundation the
gsp package ecosystem (sharing reusable agent definitions) builds on.
It lives in Genswarms.IR.* and is exposed through the Genswarms.IR façade.
Status
The IR core, the config→IR translator, the op-validation policy, the
reconcile/actuation layer, and the default validation gate are all
wired and in use. gsp and swarmidx provide authenticated package
resolution and vendoring. Runtime translation retains native package
handler refs and their explicit loader settings. Package bodies (body.md)
and policies (policy.json) are loaded from verified snapshots. Native desired IR seeds support
database-backed restart without the original configuration file;
unsupported execution translations fail rather than silently using defaults.
The two representations¶
| Representation | Module | What it is |
|---|---|---|
swarm.state (IR1) |
Genswarms.IR.State |
A snapshot of a swarm: agents, objects, topology, options — in a declared phase (desired or observed). |
swarm.overlay (IR2) |
Genswarms.IR.Overlay |
An ordered log of mutation events (add_agent, scale_agent_group, bump_package, …) that folds over a swarm.state. |
A swarm is the result of folding overlays onto a seed state:
fold/2 (Genswarms.IR.Fold) is pure: it applies events in seq order and
performs no runtime effects. Executing the result against the live system is a
separate concern (see Actuation).
swarm.state (IR1)¶
Each agent has three orthogonal slots, each answering a different question:
| Slot | Question | Example |
|---|---|---|
body |
who is the agent / what does it do (its persona, today the skills) | {"ref": "inline:researcher", "kind": "data"} |
model |
which LLM | {"ref": "openrouter:anthropic/claude-sonnet-4", "attested": true} |
backend |
where it runs | {"ref": "bwrap"} / {"ref": "oci:web", "kind": "data"} / {"ref": "apple_container", "image": "szc-agent-code:latest"} / {"ref": "ssh", "host": "pi@h"} / {"ref": "tmux", "client": "codex"} |
Objects have a handler slot (kind: code). References (Genswarms.IR.Ref) carry
a content digest when they are content-addressable (swarmidx:/oci:), or are
marked attested / represented as backend metadata when they are not
(openrouter:, ssh, apple_container).
A swarm.state must satisfy the structural invariants on parse: unique node
names, every topology endpoint exists, and slot-typing — body/policy are
kind: data, handler is kind: code. That data/code split is the privilege
boundary: data is loadable from anywhere, code is not.
swarm.overlay (IR2) — op catalogue¶
op |
Payload | Effect |
|---|---|---|
add_agent / add_object |
a full agent/object | add a node (name must be new) |
remove_agent / remove_object |
{name} |
remove the node and its edges |
add_topology_edges / remove_topology_edges |
{edges} |
add/remove edges |
scale_agent_group |
{base_name, target_count} |
materialize base#1..base#N |
bump_package |
{target, field, from, to} |
swap a slot's digest (from must match) |
set_options |
{options} |
merge into options |
update_config |
{target, config} |
merge into a node's config |
Unknown ops fail validation — they are never silently ignored, and op strings are matched against a fixed set (no atom minting).
The public API¶
alias Genswarms.IR
{:ok, state} = IR.state(state_map) # parse + validate a swarm.state
{:ok, overlay} = IR.overlay(overlay_map) # parse + validate a swarm.overlay
# apply one proposed op: security policy first, then structural fold
{:ok, state2} = IR.apply_op(state, event)
# apply a whole overlay, op by op
{:ok, state3} = IR.apply_overlay(state, overlay)
# fold a seed + overlay into the desired state
{:ok, desired} = IR.materialize(seed, overlay)
# checkpoint + log compaction
{:ok, checkpoint, remaining} = IR.compact(seed, overlay, at_seq)
# public JSON serialization (not a dump of internal structs/BEAM terms)
json = state |> Genswarms.IR.State.to_map() |> Jason.encode!()
{:ok, restored} = json |> Jason.decode!() |> IR.state()
apply_op/3 is the single choke point where both the security policy
(IR.OpPolicy) and the structural preconditions (IR.Fold) are enforced.
Native startup and recovery¶
Genswarms.start_swarm_from_ir(document) accepts a parsed JSON map in desired,
resolved form. It validates execution translation and stores an immutable public
JSON seed in SQLite before boot. Genswarms.restore_swarm(name) restores that
seed and replays successfully persisted runtime mutations. A different seed
under the same name, corrupt data, and legacy overlays without their original
seed are refused. Stopping preserves recovery data; purging deletes it.
REST accepts POST /api/swarms with {"ir": <document>} and
POST /api/swarms/:name/restore. Restart recognizes native seeds; delete=true
is refused for them because replacing a seed must be explicit. The foreground
CLI equivalents are genswarms ir start seed.json and
genswarms ir restore swarm-name (also mix genswarms.ir ...). Run the CLI
under a service supervisor for unattended operation.
Recovery needs the same database and referenced runtime assets (package files,
images, skills and operator-provided credentials), but not the original JSON
file or writer process. It does not snapshot conversations, external services,
or arbitrary Elixir closures. Use persist: true for durable runtime mutations.
If a mutation takes effect but SQLite refuses its write, the caller receives
{:error, :applied_but_not_persisted} and must reconcile before retrying; this is
not an atomic transaction spanning SQLite and external runtime effects.
From your config¶
Genswarms.IR.FromConfig translates the existing .exs/.json/.yaml
swarm configuration into a validated swarm.state:
| Config | IR |
|---|---|
skills / presets |
body {ref: "inline:<name>"} + overrides |
model: "x/y" |
{ref: "openrouter:x/y", attested: true} |
endpoint / request_extra / compact_extra |
Same fields in agent overrides, restored on runtime translation |
backend: :bwrap / :local / :mock |
bare refs {ref: "bwrap"} … |
backend: {kind, opts} for local/bwrap/mock |
bare refs with opts retained |
backend: {:docker, n} |
{ref: "oci:<n>", kind: data} |
backend: {:docker, n, opts} |
{ref: "oci:<n>", kind: data, opts: opts} |
backend: :apple_container |
{ref: "apple_container"} |
backend: {:apple_container, n} |
{ref: "apple_container", image: n} |
backend: {:apple_container, n, opts} |
{ref: "apple_container", image: n, opts: opts} |
backend: {:ssh, "u@h"} |
{ref: "ssh", host: "u@h"} |
backend: {:ssh, "u@h", opts} |
{ref: "ssh", host: "u@h", opts: opts} |
backend: {:tmux, client} |
{ref: "tmux", client: client} |
backend: {:tmux, client, opts} |
{ref: "tmux", client: client, opts: opts}; runner options such as {runner: "docker", image: "coding-tuis:latest", client_source: "runtime"} round-trip unchanged |
object.handler Mod |
{ref: "module:<Mod>", kind: code} |
object.handler %{ref, digest, path, mode} |
Native swarmidx: handler ref and digest, with {path, mode} in handler.opts |
The Apple container ref is intentionally not content-addressable in the current
IR mapping: it stores the backend choice and optional image/options metadata, but
does not claim an OCI digest. Docker keeps the existing oci:<image> mapping.
Genswarms.IR.ToConfig round-trips these Apple forms back to
:apple_container, {:apple_container, image}, or
{:apple_container, image, opts}.
Tmux refs likewise preserve the declared client and known option keys across
the config → IR → config round trip.
Backend options are also retained for local, bwrap, mock, Docker and SSH refs.
Known execution keys are restored to atom keys; JSON selectors such as
network: "isolated" and privilege_mode: "rootless" become the runtime's
atom selectors. Arbitrary string values are not converted to atoms. An Apple
ref with nonempty options must declare its image explicitly; ToConfig refuses
an unrepresentable form instead of dropping those options.
Package handlers require an explicit opts.path to the installed package and
opts.mode: "verify" | "require" ("verify" by default, matching the existing
config loader). These options describe local execution, not signed registry
metadata. Unknown load modes are rejected. The loader still checks package
bytes. Require mode compiles exactly the verified in-memory sources and records
the identities of the modules it produced. Verify mode accepts that recorded
provenance, or a signed beams map in swarm-object.json containing module-name
to sha256:<compiled-BEAM-hash> mappings. It checks the active code as well as
the BEAM artifact. An unrelated loaded module is refused. Existing verify-mode
packages without such evidence must add a build attestation or use require mode
at boot; this deliberately tightens the previous insufficient check. Explicit
require mode recompiles a preloaded, unproven entry from the verified snapshot;
it never borrows that entry's existing identity. Modules already bound by this
loader cannot silently change digest or code while another swarm uses them.
Native data refs also use explicit opts.path: body packages contain UTF-8
body.md, deployed as package-body.md with skill template substitution; policy
packages contain policy.json, an object/list sent as request_extra.policy_ir.
Policies remain data interpreted by the selected router, never evaluated as
Elixir. The pinned policy takes precedence over an override's policy_ir.
Runtime observation preserves the native refs and original overrides rather
than replacing their identities with generated inline skills. Inline skill
entries may also be {"name":"persona.md","content":"..."}; names are plain
filenames. Bind options use JSON [source,target] pairs and become runtime
tuples only for extra_ro_binds/extra_rw_binds.
Package snapshots reject symlinks and special files and are limited to 10,000 files/64 MiB. The same snapshot is hashed and consumed, so compilation never reopens a subsequently modified source file. Package code and the operator's filesystem remain trusted execution inputs: a signature is not a code sandbox.
State.to_map/1 and Ref.to_map/1 emit the public JSON shape, retaining native
refs, model-policy wrappers, execution metadata and explicit null options.
They do not make arbitrary Elixir values portable: functions, tuples and
other non-JSON metadata must not be treated as serialized IR checkpoints.
The default control-plane gate¶
The IR is wired into the orchestrator as a strict, fail-closed gate
(Genswarms.IR.Gate), so it governs every swarm without changing how agents are
spawned:
- On swarm start — the config must translate to a valid
swarm.state(the §6 invariants). An invalid or untranslatable config is refused before spawning. - On
add_agent— rejects host-escape backend config keys (subzeroclaw_path,extra_ro_binds,extra_rw_binds,extra_path) and the per-swarm agent cap. The key restrictions apply both to agentconfigand backend tuple options / native IRbackend.opts. - On
scale_agent_group— enforces the agent cap.
The cap defaults to config :genswarms, :max_agents_per_swarm (100) and applies
to dynamic mutations, not to operator-authored configs.
Actuation¶
Genswarms.IR.Reconcile.plan(desired, observed) computes the ordered actions
that bring a live swarm to a desired state (start/restart/stop nodes, add/remove
edges) — pure, no runtime access.
Genswarms.IR.Executor runs that plan against the orchestrator: it reads the
live config (observed) and translates each action into a SwarmManager call.
Executor.reconcile(swarm, desired) does the whole loop — observed → plan →
apply.
All runtime spec conversions are checked before the first mutation in a plan.
An unsupported body/policy, missing handler binding or invalid runtime option
returns {:error, {:invalid_runtime_spec, one_based_position}}, without dumping
configuration values. A restart also stops if removing the old node fails;
it does not attempt to add the replacement anyway. This preflight is not
transactional rollback of runtime failures after valid actions have begun.
Design spec¶
The normative format and semantics (the ref model, the §6 invariants, the op catalogue, fold/materialize/compaction) are defined in the GenSwarms IR specification. This page documents what is implemented and usable today.