The boot chain
Acquire, verify, phase walk, steady state. Fail-closed at each boundary with a named reason on stderr.
Entry vectors
| initramfs / VM / metal | existing Linux host | |
|---|---|---|
| entry | /init = bootstrap.sh | curl …/boot | sh |
| PID 1 | numinit | unchanged |
| PID 1 takeover | by design of the image | --take-pid1 prints procedure only |
Host installer never silently replaces init.
Stage 1 — bootstrap.sh
1. ARCH=$(uname -m) → canonical (generated ArchTarget table)
2. resolve init from manifest:
F numinit-$ARCH if published
else numinit.sh
3. acquire artifact (local verified prefer; fetch if missing)
4. sha256 verify — fail-closed
5. verify numos.state C hash
6. exec numinit
(mount /proc /sys /dev: initramfs or host — not bootstrap)Unknown arch halts. Hash mismatch reports expected and actual digests. Init selection is manifest-authoritative: the arch table answers “could a static build exist”; the manifest answers “is one published.” Conflating those announced static and fetched shell.
Offline / self-contained initramfs: images produced by the
build skills set NUMOS_OFFLINE=1,
NUMOS_PREFIX=/opt/numericalos, and pre-populate that prefix with
numinit.sh, manifest.txt, and a verified
numos.state (often also at /etc/numos.state).
/init is a thin wrapper that exports those variables then execs
packaged bootstrap.sh. Without offline mode, bootstrap tries to
refresh the manifest over the network and will halt if no HTTP client is
available.
NUMOS_OFFLINE=1 refuses fetch. Tampered local copy: halt, leave
file (evidence). Failed fetch: delete partial. Online hosts still prefer a
verified on-disk artifact when present.
numinit binaries are published yet.
The manifest currently carries only the shell floor, so every architecture
resolves to numinit.sh. Building the per-arch static binaries needs
a Zig toolchain and is separate work; the resolution logic that will use them is
in place and tested.
Stage 2 — numinit as PID 1
It walks BootPhase records by ordinal. Within each phase it
resolves the unit DAG from requires and after edges,
then runs units in dependency order.
The resolver is iterative — Kahn's algorithm — and that is not a
style preference. POSIX shell has no local, so every variable in a
function is global; a recursive resolver's recursive call clobbers its caller's
loop variable and returns the wrong unit. The iterative form has no such
hazard.
Unit kinds
| kind | behavior |
|---|---|
oneshot | runs synchronously; failure propagates to the phase |
target | synchronous grouping point |
longrun | backgrounded; the phase continues once it starts |
A backgrounded longrun unit's failure is not visible to
its phase's failure policy — nothing observes its exit code. That matches
how real supervisors behave, and it is worth knowing before you write a state
that depends on the opposite.
Failure policy
| on_failure | result |
|---|---|
halt | boot stops, nonzero exit, stderr names the phase; later phases do not run |
degrade | boot continues; the node advertises degraded=1 so the coordinator stops scheduling onto it |
continue | boot continues, logged, not degraded |
degrade is the interesting one. A partially broken node stays in
the topology and says so, rather than dropping out entirely or pretending to be
healthy.
A note on halting from shell
Fail-closed in POSIX shell has a trap that this project fell into and had to
dig out of. exit inside a command substitution terminates only the
subshell. A helper that calls numos_die from inside
$(...) prints its alarming message to stderr, and the caller then
continues with exit status 0.
Two conditions — a dependency cycle and a dangling unit reference —
"booted successfully" this way while printing numos: HALT:. Every
such substitution is now captured to a variable and status-checked, and the
tests for those paths assert on exit status, not on the presence
of the message, because the broken version emitted the message too.
Steady state
After the phase walk, numinit enters a tick loop. This is what
makes it an init system rather than a boot script — and what makes it a
valid PID 1 at all, since a PID 1 that returns panics the kernel. In a real
boot the loop never exits.
Each tick does three things:
- Supervises every
longrununit whose PID was recorded when the phase walk started it. A unit that has died is reaped — which is also the zombie-reaping duty, since the shell holds a terminated child's status until something waits for it — and then itsrestartpolicy decides what happens:neverlogs and stops,on-failurerelaunches only on a nonzero exit,alwaysrelaunches regardless. Each relaunch advances that unit's own backoff, doubling to its ceiling. - Runs health predicates that are due.
interval_sis counted in ticks, and a tick is one second by default, so the two agree by construction. A passing probe resets the unit's consecutive-failure count; a failing one increments it, and reachingthresholdfires. - Applies
local_actionon a fire:degrade-nodemarks the node degraded,restart-unitsignals whichever unit declared that predicate as itshealth_probe,noneonly reports. An unrecognized action halts rather than being silently ignored.
Escalation never blocks the loop. An on_fire
swarm reference is appended to a queue in the runtime directory and drained
later. An offline machine has to keep supervising itself, so reachability is
never on the critical path of a tick.
numctl status named-halts.
Floor implements identity-init, capability,
join (local state + optional coordinator POST), and
run-op as a dispatch edge. Not a query API for a running
numinit.
Observed under QEMU
Self-init profile (graph-resolved offline distro) on x86_64
under QEMU 10.2.2 — both kernel+initrd and hybrid ISO/GRUB serial
(-cdrom):
numos: arch x86_64 numos: init numinit.sh (shell floor) numos: state verified numos: boot complete degraded=0 numos: steady state entered
ISO chain observed: SeaBIOS → El Torito → GRUB 2.06 → kernel → initramfs →
/init. Serial entry must use only
console=ttyS0 (last console= becomes
/dev/console). See
what is proven and
docs/observations/2026-08-09-self-init-iso-cdrom.md.
Fleet profile on the same emulator still ends
degraded=1 when net/clock/join units lack eth0/ntpd/coordinator —
expected, not a packer failure.