Skip to content

Versions and upgrades

Three versions govern a Helix deployment, and confusing them is the usual cause of a puzzling install. This page is the one place they are all explained: what each governs, which of them is actually enforced, how to see what a node is running, and how to move it forward safely.

If you only take one rule away, take this one: float while you develop, pin for anything live.

where it liveswhat it governsenforced?
Corehelix/runtime/VERSION — an image tagthe engine: Spiral, crud, dispatch, the SPAno — provenance only
SDKhelix/custom/package.json, exactthe contract between your extension and the platformyes — deploy refuses a mismatched minor
Client bundlehelix/custom/package.json versionyour extension, domain, map and configno — recorded, never checked

Only the SDK decides whether a pairing is allowed to run. A 0.1.x bundle runs on any 0.1.x core; a 0.1 bundle on a 0.2 core is refused outright. The core version is provenance — it tells you what a thing was built and signed off against, which is what you need when something misbehaves, but it never blocks a deployment on its own.

That is why the core can float while the SDK cannot: picking up a newer core is a fix, picking up a different contract is a rebuild.

Two tags move:

  • sdk-<MAJOR.MINOR> (e.g. sdk-0.1) — always the newest core on that SDK line. This is what a client repo develops against.
  • latest — always the newest core, full stop.

Anything else (0.0.5d, 1.2.0) is exact and never moves.

A live node may not run a floating tag. spiral, spiralOnly, create, replicant and standby refuse to start:

helixctl: 'replicant' is a live mode but the image is the floating tag 'latest' (currently core 0.0.5d).
A live node must state what it runs. Pin it:
helixctl pin # writes 0.0.5d to /srv/helix/runtime/VERSION
dev, tuning and demo may float.

The reason is the one in the message: a node on a moving tag cannot answer “which core is this?”, and that is precisely the question you need answered when a deployed system is behaving strangely. helixctl pin resolves the float to the exact tag it currently points at and writes it — one command, and the node can then account for itself.

dev, tuning and demo are exempt by design. They mount your repo and switching core is part of the job. That difference — a working copy floats, a release is pinned — is the whole distinction.

Three views of the same fact, from three sides.

From the host — helixctl version:

runtime : /usr/bin/podman (podman)
image : registry.gitlab.com/honeywick-consulting/helix/helixcore:0.0.5d
pinned tag : 0.0.5d (helix/runtime/VERSION — the tag helixctl pulls)
core version : 0.0.5d (true helixCore baked in the image)
SDK (image) : 0.1.8 (contract the image provides)
SDK (client) : 0.1.6 (deployed bundle built against)
core (client): 0.0.5c (deployed bundle built against)
client (image): none (client-agnostic core; the client comes from a deployed bundle)

Read it as two pairs. pinned tag versus core version says whether you are on a float and what it resolved to. The two (client) lines say what the deployed bundle was built against — so the example above is a bundle built on 0.0.5c now running on 0.0.5d, which is fine (same SDK line) and which deploy will have mentioned in passing when it landed.

From the container — every node prints its identity at boot, taken from the image itself rather than the tag it was pulled under:

[startHelix] start=replicant mode=replicant env=live joinable=1 client=AA core=0.0.15 bundle=AA-1.4.0 sdk=0.1.6 | mongo:durable(ext4)@…

From the artifact — the bundle’s client/manifest.json:

{ "company": "AA", "sdkVersion": "0.1.6", "version": "1.4.0", "coreVersion": "0.0.5c", "coreRegistry": "registry.gitlab.com/honeywick-consulting/helix/helixcore", "commit": "4d5ee84…", "branch": "main", "clean": true, "dirtyPaths": 0, "upstream": "origin/main", "ahead": 0, "behind": 0 }

Since core 0.0.13.7 the manifest also names the repository that core image came from (coreRegistry), so <coreRegistry>:<coreVersion> is the exact image the bundle was built in. Since 0.0.13.6, release also records where the bundle was built from: the commit and branch, whether the bundle’s inputs were clean (clean / dirtyPaths count only uncommitted changes under helix/custom/, helix/scenarios/ and the VERSION pin, so an edited test elsewhere does not count), and how the branch stood against its upstream as of the last fetch — the build never goes to the network for it. A bundle built outside a git checkout records these as empty or null. The manifest is served with the extension, so a running node exposes these fields just as it exposes the version.

One command builds a release, in one of four forms. All of them resolve your pin to an exact core, build your extension inside a container of that core, and refuse if the two disagree — so the machine you release from needs no node, npm or client toolchain, only the container runtime it already had.

Terminal window
./helix/runtime/helixctl release [version] [--as studio|set|bundle|image|core-set]

The version is optional. It defaults to your client’s helix/custom/package.json version — the same number the About dialog shows, because the build injects it there too. So a release is reproducible from a checkout: git checkout <tag> && helixctl release produces the same artifact, with nothing to retype and nothing to keep in step. Give a version explicitly to override it — a release candidate, or rebuilding identical source against a different --core.

Ordered from most self-contained to least:

--aswhat you hand overthe node takes it withthe node then recordswhen
studiohelix-<client>-studio-<ver>.tar — the bundle plus the repo’s own sourcehelixctl import <tar>studioa demo instance or a tuning lab. Never a production site
set (default)one tar: core image + bundle + launcher + pinhelixctl installsetair-gapped, or a controlled production estate
core-setthe core alonehelixctl install, then importcoreOnly, then bundlebootstrapping or air-gap — see below
bundlehelix-<client>-<ver>.tarhelixctl import <tar>bundlethe node already runs the core you want
imageone image, everything baked inhelixctl pullimageyou deploy from a registry

studio is the only form that carries source, and that is why it is a form rather than an option on another one: the three that reach production cannot accidentally carry it. A set’s bundle becomes the node’s own client.tar and is unpacked at every start; an image is pushed to a registry. Inside a studio artefact, beside client/ and helix-config/, sits a source/repo.bundle — a git bundle of the repo at the commit the manifest records — so a machine with no credential, no registry login and no network can mount the client repo the simulator start types need. See Stand up a demo server.

What the node records, and why it matters. Every node keeps a manifest.json saying what it is and how it got there, and delivery.method is one of those five: the four client release forms, plus coreOnly for a machine holding a core and no client — which is not a release at all. The artefact declares its own form (a bundle in its own manifest, an image in a label) and the node inherits it, so the record cannot disagree with what was handed over. It is what tells you how a node can be cloned: a set node holds the core archive and can seed a machine that can reach no registry; a bundle node tells the target to pull and sends only the bundle.

Useful flags: --core <tag> releases against a specific core rather than your current pin; --from <dir> points at a client repo elsewhere; --image-ref and --no-push apply to --as image.

The extension, domain, map and config, for a node already on the right core.

Terminal window
./helix/runtime/helixctl release 1.4.0 --as bundle
# on the node:
./helixctl import helix-<client>-1.4.0.tar # take it into the release area
./helixctl <CLIENT> start replicant helix-<client>-1.4.0.tar # start SELECTS it

The artefact is named at the start, not installed into place beforehand: import acquires it and the start selects it, which is the split between what can be done while a node runs and what needs the downtime. A version is a pair — the bundle’s manifest says which core it was built against, so naming one file pins both halves.

Terminal window
./helix/runtime/helixctl release 1.4.0
# -> helix-<client>-release-1.4.0.tar (core image + bundle + helixctl + VERSION)
tar xf helix-<client>-release-1.4.0.tar && cd helix-<client>-release-1.4.0
./helixctl install # load the image, take the artefacts in
./helixctl <CLIENT> create <name> # then: start replicant

An image — one artifact, nothing to install

Section titled “An image — one artifact, nothing to install”
Terminal window
echo registry.example.com/helix > helix/runtime/CLIENT_REGISTRY # once
./helix/runtime/helixctl release 1.4.0 --as image # builds and pushes; --no-push to hold back

The image is the exact core with your sealed bundle baked in, labelled with the client, its version and its SDK. On the node, point helix/runtime/REGISTRY and VERSION at it and helixctl pull — there is nothing to deploy. helixctl reads the label at launch and mounts no bundle; at that point it refuses if the node also has a deployed bundle, or if the image is baked for a different client, so there is only ever one answer to what the node is running.

The refusal is at launch, not at import. helixctl import does not read the label, so taking a bundle in on a node pinned to a client image succeeds — and every later launch then fails, because the node has two answers. If you are moving a node off a baked image onto a bundle, pin it to a client-agnostic core in the same breath.

--as core-set is the odd one out: no client repo, no build, no bundle. It is the core travelling on its own — the exact image, the launcher from it, and a pin — for the two situations where a client bundle is not the thing being moved:

  • Bootstrapping a machine that has no core yet, ahead of any client.
  • An air-gapped estate, where the core arrives separately from the client.

It is normally cut by the core release itself (publishHelixCore.sh <ver> --set) rather than by hand. install recognises a set with no bundle and runs two steps instead of three:

Terminal window
tar xf helixcore-release-<ver>.tar && cd helixcore-release-<ver>
./helixctl install # loads the image and pins VERSION — that is all
./helixctl import <client-bundle>.tar # the client arrives separately
./helixctl <CLIENT> create <name> # then: start replicant — or start replicant <client-bundle>.tar
Terminal window
./helix/runtime/helixctl pull # brings the core AND the new launcher (the stub self-updates)
./helix/runtime/helixctl pin # resolve the float to the exact tag
./helix/runtime/helixctl version # confirm

To avoid pulling a moving tag at all, write the exact tag first — echo 0.0.5d > helix/runtime/VERSION — then pull. You fetch precisely what you asked for and never touch the float.

Whether the client bundle needs rebuilding depends only on the SDK. Same MAJOR.MINOR, and the deployed bundle keeps serving; a moved minor, and you rebuild and redeploy.

An upgrade is not a verb of its own. It is three of the five, in order, and the middle one is the point:

Terminal window
cd ~/HelixRuntime/aa-prod
./helixctl import helix-aa-1.4.1.tar # take it in; nothing running changes
./helixctl AA stop
./helixctl AA start replicant helix-aa-1.4.1.tar

Importing changes nothing that is running, so a version can sit in a node’s release area for as long as you like before anyone chooses it. That is what makes rolling an estate forward node by node free until the moment of choosing — and it is why the two halves are separate commands rather than one.

The version is confirmed when the node starts. start reads the artefact’s own manifest for the core it was built against, pins that core, obtains it (from the release area if the archive is there, otherwise the registry), places the bundle, and starts. One artefact name resolves both halves of the pair, which is why you name a file rather than a core tag — a tag can only ever set half of it.

Compatibility is checked at start, against the tar the node is about to run: a bundle whose SDK MAJOR.MINOR differs from the image’s is refused there. That is deliberately the last possible moment, because an artefact can arrive by import, by scp, or by hand, and start is the only thing every one of those paths passes through.

If the SDK has moved, the bundle has to be rebuilt against the new core on a machine that has the client repository — release --as bundle — and imported like any other version. A deployed node has no custom/ beside it and cannot rebuild, which is the right answer rather than a missing setting.

Several nodes on one machine is a loop you write, and can see:

Terminal window
for h in ~/HelixRuntime/*/; do ( cd "$h" && ./helixctl import helix-aa-1.4.1.tar ); done

There used to be a helixctl upgrade that did the whole move in one command, and before that a sweep driven by a hand-maintained helix-tenants file. Both are gone. The file was the only thing on a machine nobody was ever prompted to update, so it went stale between an install and the upgrade that trusted it. upgrade went because it was the sequence above with the choice of version taken away: it re-ran whatever command the node had recorded for itself, which meant the one thing you most want to be explicit about — which version — was the one thing it decided for you. Nothing ever called it but a person, and Mission Control’s Import and Start do the same work with the version named.

Not with this command. A node has no channel to its peers, and a rolling upgrade has to know which member is writing so it can step that one down last — which is cluster knowledge, and lives in Mission Control. Host and cluster are different axes: one machine may hold instances of two clusters, and a cluster’s other members are on machines this one knows nothing about.

The procedure depends on one thing, and Mission Control reports both halves of it on the primary’s card — what the world is stamped at, and what the running build reads and writes:

compatible schemasa schema floor has moved
what it meansthe new core can read the world as it standsthe world has to be converted before the new core can use it
the procedureper member: stop → install → start. Secondaries first, then one stepdown, then the old primary lastpause the cluster, install on the primary, let it convert at boot, then install and restart the others
why that orderone election instead of one per member; a secondary is not the writer, so it needs no stepdownthe schema gate runs at boot, and replication is not gated — a primary converting while old code is still serving reads would stream it documents it cannot interpret

The secondaries never convert. They receive the converted world by replication, stamp included, so what they need is the new binary, not a reload. And once the primary has converted, an older core cannot boot against that world at all — it refuses, naming both versions — so installing the rest is compulsory rather than optional. The oplog holds at least 24 hours, so a member can be out for a maintenance window and still catch up instead of needing a full resync.

helixctl install on a node will refuse while that node is running: software is not swapped under a live node, and the far side is asked rather than assumed. That refusal is what makes one-at-a-time the default.

Run helixctl help on any node for the same list.

commandwhat it does
check (alias doctor)audit host readiness: run / client-dev / core-dev (runs automatically before pull)
loginlog in to the image registry (once per server)
pull [--force] [--no-prune]fetch the pinned image only if it is not already on this host — an exact tag already present is used as it stands, so the registry need not be reachable. A floating tag (latest, sdk-*) is always re-checked. --force re-checks an exact tag (after one has been republished). Then prunes old core images unless --no-prune
load [tar]load an image tar on an air-gapped host; refuses to guess between several
pin [tag]pin VERSION to an exact core, resolving the current float
pack [out.tar]a copy of client.tar, for redistribution — the artefact itself, not a repack
harness [dir]pull the Spiral test harness (testSpiral, testSpiralRun) out of the image into tests/
versionwhat this node runs — image, pin, core, SDKs, baked client
stop / logs / shellact on the running container — found, not assumed: a node names its container differently per mode, and these look for any of them. With several up at once (concurrent spiral feeds are a normal case) they list what they found and ask for HELIX_CONTAINER_NAME rather than picking one
factsone JSON object: what this machine is, as the launcher sees it — runtime, pin, image and whether it is present, client, seed directory, containers that are up. It starts nothing and reaches no registry, and it answers on a machine with no container runtime at all (saying so). Written for the node agent; useful to anyone scripting against a host
helpthe same reference, on the node
commandwhat it does
release [ver] [--as studio|set|bundle|image|core-set]build a release in a client or demo repo; ver defaults to the client’s package.json — see §4
install [set.tar]install a set on a node, or clone another node: load, pin, place the bundle if the set carries one.
import [artefact]take a version in from staging/ into this node’s release area. With no argument it lists what is on offer. Nothing running changes
<CLIENT> start <type> [artefact]start a node. Naming an artefact confirms the version: its manifest gives the core, which is pinned and obtained, and the bundle is placed before the node comes up
release --to <dir>where a built artefact lands. The printed path is what a later environment’s staging is pointed at — a release is pulled, not pushed

helixctl <CLIENT> start <type>, mirroring startHelix <CLIENT> <startType>. The action is start; the type is its argument, and it decides the mode the node runs as — solo, multiplexor, replicant or standby. primary is not among them: it is whichever replicant holds the write, which an election decides.

start typemodewhat it is
demo [artefact|repo]solothrowaway, many at once, each on its own writable copy of the client repo — try or show the product, and what a public demo server runs. In your repo it needs no argument; name a studio artefact and the repo is restored from inside it, with no credential and no network (needs git) — see Stand up a demo server
dev <repo>solomount the client repo and rebuild the extension on save
tuning [artefact|repo]solothe same node as demo but on the actual tree, so a tuner’s saves persist — domain tuning and the Spiral Laboratory
replicantreplicanta durable voting member. Given HELIX_PRIMARY it joins a set; given none it brings this machine’s own world back up
standbystandbya voting member at priority 0 — never elected; with a vault directory it is the cluster’s capture member
spiralmultiplexorfed anchor — the full stack driven from stdin; stops when its input ends
spiralOnly—the raw optimiser as a command-line filter; not a Helix node at all

And one word that is not a start type, because it is a ceremony rather than a kind of node:

what it is
helixctl <CLIENT> create <name>makes a NEW cluster, names it once, and refuses if this machine already holds a world. What it leaves running is an ordinary replicant

The whole deployment — the three nodes, the ports between them, and the one address your operators use — is Deploy a cluster.

Cluster modes need to know where this node is, because the other members address it directly:

Terminal window
export HELIX_PUBLIC_HOST=fred # this node's reachable NAME (prefer it to an address — see Deploy a cluster)
export HELIX_PORT=8001 # the SPA port browsers use
export HELIX_MONGO_PORT=3001 # the replica set — chosen for you when unset; name it for a cluster
export HELIX_PUBLIC_CRUD_PORT=8090 # optional, recommended: fixes the port the other nodes reach
export HELIX_PRIMARY=fred:3001 # to JOIN a set (replicant / standby); omit it to bring back a world already here

Three ports are published, and only two are the cluster’s business:

PortPublished asWho reaches it
mongothe same number inside and outevery other member, in both directions — replica-set heartbeats are mutual
CRUDHELIX_PUBLIC_CRUD_PORT, else a port picked at startthe other nodes, whose SPA proxies writes to the active writer
SPAHELIX_PORT, else a port picked at startbrowsers only — never needed for joining

Name the CRUD port unless you enjoy firewall rules over a whole range: unset, it is picked afresh on every start. Because mongo’s port is published unchanged, a host runs one node. Secrets (the Spiral licence, map and any client keys) are forwarded from the launching environment by name, so a service or cron wrapper must export them itself — a non-interactive shell does not read your profile. And the licence is keyed to the hostname the container reports, so every node needs its own.

For the two stdin-driven modes there are launch commands — drop-ins for a Spiral binary, which a system outside Helix can run with no arguments at all:

Terminal window
./spiralOnly < feed.json > records.json 2> diagnostics.json # the raw optimiser
./spiral < feed.json > records.json 2> diagnostics.json # the full stack

The client’s bundle is the pin. A bundle records the client and the exact core image it was built in (<coreRegistry>:<coreVersion>), so a core that disagrees with its bundle cannot happen. Nothing else chooses the version: there is no VERSION read, no REGISTRY file, no stamp, and no option to run another.

Name the file for its versions where you can — <Client>-<ver>-core-<core>.tar — so an artifact can be identified wherever it travels. Helix does not require it: where the directory is read by something outside Helix that expects one path, a fixed name and replace-in-place is a perfectly good answer, and the AA’s AA_helixConfig.tar is exactly that case.

Nothing in Helix infers a version from a bundle’s name, whichever you choose. The version lives in two places that can be trusted: client/manifest.json inside the tar, and the node’s own record of what it was installed from (helixctl node-manifest) — which the launcher reads and passes on when a node seeds another. A name is an address, not a description.

They are one line each, and your build pipeline writes them (core 0.0.14s):

#!/bin/sh
exec env HELIX_QUIET=1 HELIX_HOME=/opt/aa-config HELIX_IMAGE=…/helixcore:0.0.14s \
HELIX_CLIENT_TAR=/opt/aa-config/AA_helixConfig.tar \
/opt/aa-config/helixctl AA start spiralOnly "$@"

Everything a run needs to decide is decided at build time, by the only thing that knows it: the pipeline that just produced that tar on that core. There is nothing to search for, nothing to disambiguate, and no way to run the wrong bundle. The running itself belongs to helixctl, the one owner of every container Helix starts — the hostname pin, the signal handling, the mount forms, the ports, and the handover of this host’s own resolver and the launch account’s ssh identity. Your pipeline installs it beside them, from the same core image, and replaces all of it together.

That pipeline is a few steps — helixctl release --as bundle, deploy, lift helixctl out of the image, write the two commands — so Honeywick will write the one-command tool that does it for your environment, checks the result and reports what it built. It is part of fitting Helix to a system that already exists, not part of the product: the AA’s bundleClient is an example of one. A build there never overwrites an earlier tar, so the directory keeps a readable history and an operator can always say which bundle a run used.

Where the commands live does not matter, and neither does the working directory — both paths are written in. Earlier cores worked the other way round: until 0.0.14r the commands searched the working directory and then HELIX_CONFIG for a <Client>_helixConfig.tar, with HELIX_CLIENT to pick between several. If you have that arrangement, rebuild with a current pipeline; the old commands still work against the tar they find.

The commands take no arguments, so everything else comes from the environment. Keep it in one file the launching account sources — a service or cron wrapper must, because a non-interactive shell does not read a profile, and an unset secret is simply not forwarded.

Terminal window
# /etc/helix/fred.env — chmod 600: it holds a licence and a password.
# . /etc/helix/fred.env && ./spiral < feed.json > records.json 2> diagnostics.json
# ── This node ─────────────────────────────────────────────────────────────────
export HELIX_PUBLIC_HOST=fred.example.com # how the other nodes and browsers reach it
export HELIX_SITE=london # where it is; drives read locality and node binding
# ── Ports ─────────────────────────────────────────────────────────────────────
export HELIX_PORT=8001 # SPA — browsers only
export HELIX_MONGO_PORT=3001 # the replica set — every member, in BOTH directions
export HELIX_PUBLIC_CRUD_PORT=8090 # writes the other nodes forward to the writer
# ── The administrator ─────────────────────────────────────────────────────────
# OPTIONAL. Leave both unset and helixctl creates `admin` with a four-digit code,
# prints it when the node starts, and requires it to be changed at the first
# sign-in. Set them to choose the account yourself; set the password to nothing
# (HELIX_SEED_PASSWORD=) to say this administrator needs no password at all.
export HELIX_SEED_USER=ops@example.com
export HELIX_SEED_PASSWORD='…'
# ── Secrets ───────────────────────────────────────────────────────────────────
export SPIRAL_LICENCE='{"set":{"licence":"…"}}' # keyed to the hostname this node reports
export GOOGLE_MAPS_API_KEY='…'
# ── So a run can be explained ─────────────────────────────────────────────────
export HELIX_LOG_DIR_HOST=/var/log/helix # crud.log, spiral.log, 2crud.log on the HOST
# (defaults to <node>/storage/logs — see "Storage and logs")

The node’s ssh identity is not in that file, and should not be. So that Mission Control can ask another machine whether it could host a node, the launcher lends the container the launch account’s own ~/.ssh, read-only, together with an ssh-agent if one is running. It works the account out from SUDO_USER, else the passwd entry for the uid it is running as — deliberately not $HOME or $USER, which sudo, a systemd unit and cron each rewrite to the account doing the running rather than the one that asked.

The rule to remember is that the node can do exactly what a non-interactive ssh from that account can do, and nothing more:

Terminal window
ssh -o BatchMode=yes <other-host> true # this must succeed, as the account that launches Helix

If it does not, neither will Helix, and for the same reason. A Kerberos ticket or a typed password gets you in at a prompt and cannot cross into a container — so a service account whose ~/.ssh holds a known_hosts and no key needs a keypair of its own (ssh-keygen, then ssh-copy-id). The launcher says so at start-up when the identity it is lending has no key in it. HELIX_SSH_DIR_HOST=<dir> names a different directory, and HELIX_SSH_DIR_HOST= (empty) lends nothing at all.

Helix generates no keys and stores no credentials: it borrows an operator’s reach rather than accumulating its own. The other side of that bargain is worth stating plainly — a node lent an identity can reach whatever that identity can reach, and it is also the process that loads your client extension at runtime. Empty is how a deployment that does not want that says so.

If fred is one of several nodes, add the three that make a cluster of them:

Terminal window
export HELIX_JWT_SECRET='…' # THE SAME long random string on every node
export HELIX_COOKIE_DOMAIN=.helix.example.com # so a session follows a client between nodes
# export HELIX_PRIMARY=fred.example.com:3001 # JOINERS only — fred is the first, so not here

There is no list of node addresses to maintain. A client asks any node it can reach and is told the others, with a recommendation, through the binding service — the membership comes from the nodes themselves. The one thing you may want to set is the opposite: a subset a given node is allowed to offer, when a region’s clients should stay on a region’s nodes.

Terminal window
export HELIX_BIND_ALLOW=fred.example.com,barney.example.com

On top of the tar, each launch can overwrite the domain and the map. The first column with an answer wins:

1. flag2. environment3. current directoryotherwise
domain--domainSPIRAL_DOMAIN./<Client>.domain.jsonthe tar’s own
map--mapSPIRAL_MAP./<Client>.map.jsonthe tar’s own

The third column is what makes them a drop-in for the legacy layout — the two JSON files sitting in the directory Spiral runs in, under their canonical, capitalised names. The lower-case legacy name (aa.domain.json) is deprecated: it is not picked up from the working directory and is not staged in the tree, so a stale copy cannot quietly win over a new tar; a lower-case file you still mean to run is named in SPIRAL_DOMAIN, where it is visible. Whichever domain and map end up in force are also staged under the helix/custom/-qualified name a run file captured from a repo root might include. A set-but-missing overwrite is fatal rather than ignored: one silently dropped across a parallel test fan-out reports a green pass against the wrong domain.

The engine is not an overwrite. Until 0.0.14 a spiral.l64 in the working directory, or --exe, or SPIRAL_EXE, would be rotated in on top of the image’s. That is gone. A container runs the engine its image was built with and there is no way to hand it another, so the core recorded in the bundle’s manifest is the complete answer to which Spiral produced a given result — today and when someone asks again next year. A spiral.l64 left in the directory from the legacy layout is ignored rather than adopted.

Trialling a Spiral build is a real need and a separate one: the Spiral Laboratory runs a batch against a candidate engine, which is named to the test harness and never becomes the engine a client is quietly running.

They are silent unless they fail. Stdout is Spiral’s data and stderr is Spiral’s own record stream, whose second line test harnesses read for Spiral’s version — so the commands add nothing to either: no banner and no progress. On a server’s first run the core is pulled, quietly, if it is not already loaded, so that run is slower; after that the container is invariant and only the overwrite files change per test. Anything a container runtime prints is shown only when something fails. spiral does not print its address either — set HELIX_PORT to fix the SPA port.

They last as long as their input. At end of stdin Spiral finishes what it was given, the container stops, and the command returns Spiral’s exit status — ./spiral < feed > out returns just as spiral.l64 < feed > out does, rather than leaving a finished world running. spiral needs SPIRAL_LICENCE exported and refuses to start without it; if the stack itself cannot start, the reason is printed on stderr as a helix: line, since there is then no Spiral stream to protect.

They behave like the binary when signalled. Each run is one container, started in the foreground with a small init process, so a signal sent to the command (Ctrl-C, a harness’s timeout, a hang-up) reaches Spiral as it would reach spiral.l64, and the command returns the exit status the binary would have. A signal that cannot be caught — a harness escalating a timeout to SIGKILL — leaves the container with nobody attached; nothing can run in a process that has been killed, so it is removed by the next run instead.

Their inputs are staged once and reused. The first run of a tar (with a given set of overwrites) unpacks it, rotates in whichever domain and map are in force, and re-packs the result into a cache on the host: $HELIX_CACHE/spiral, else $XDG_CACHE_HOME/helix/spiral, else ~/.cache/helix/spiral. That staged copy is mounted read-only, so parallel runs share it safely, and later runs with the same inputs stage nothing at all. Entries unused for a week are removed. Set HELIX_CACHE where the home directory is not writable.

They pass -i without a TTY (which would mangle the stream), and each command execs, so the file descriptors go straight through. How this compares with every other mode — which ones mount your repo, which mount a sealed tar read-only — is laid out in How your client reaches the container.

Call them by path, or symlink them in — the tar and the core are written into the command, so neither where the command lives nor where you run it changes what runs:

Terminal window
cd /tests/run42 # may hold AA.domain.json / AA.map.json to overwrite the tar's
/opt/aa-config/spiralOnly < feed.json > records.json 2> diagnostics.json

The working directory still decides the overwrites (the table above), which is the whole point of being a drop-in: a test directory carrying its own domain gets its own domain. It no longer decides the bundle.

To run a different bundle with the same commands, name it: HELIX_CLIENT_TAR=/path/to/another.tar /opt/aa-config/spiralOnly < feed.json. That is the same override the command itself uses, so comparing two builds is one variable rather than two directories.

Mode and environment are the two axes a node has: dev, tuning and demo all report mode solo, while the environment — a name the client chooses, live, test, acceptance — says how seriously to treat the instance. Both are printed at boot. (Until 0.0.15 the second axis was a closed three-value “deployment class”; it became a name, so a client can add an environment without a core change.)

Container log levels follow the same split: dev and tuning log at INFO — they exist for watching the system, and INFO carries the load/snapshot timings — while every other mode logs at ERROR (spiral raises itself to WARN). HELIX_LOG_LEVEL overrides in either direction; the ambient LOG_LEVEL is deliberately ignored, because a stray DEBUG in a launching shell once flooded a production dispatch log to gigabytes.