Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

ONIX is an experiment in building a small, atomic, musl-based Linux distribution with:

  • Moss as the package/state manager
  • Boulder as the .stone package builder
  • systemd as PID 1, if the musl path keeps working
  • systemd-boot as the bootloader for the real ONIX image
  • Nix as the development toolbox, not as the target package manager

This book is the canonical learning document for the repository.

The root README.md stays short on purpose. The detailed explanations live here, because each build step now needs room for:

  • what the step does
  • why it exists
  • what files it reads and writes
  • what host/guest boundary it crosses
  • what it proves
  • what it intentionally does not prove yet

The most important mental model

We are not installing a distro by running one magic installer.

We are building it layer by layer:

temporary forge VM
  -> package tools
  -> first packages
  -> package repo
  -> root tree
  -> disk image
  -> bootloader
  -> kernel/initramfs
  -> init system
  -> booting ONIX machine

Every phase is a small proof. When one phase succeeds, the next phase is allowed to depend on that proof.

Branding rule

The project name is written as:

ONIX
onix

Do not use mixed-case spelling.

Quickstart

Use the repository Makefile for normal work.

make doctor      # common health check
make phases      # print the numbered learning flow
make phase 0     # run all Phase 0 steps
make phase 1     # run all Phase 1 steps
make phase 2     # run the canonical Phase 2 path
make phase 3     # explain deferred ONIX-owned kernel work
make phase 4     # run the Phase 4 booted-base readiness lane

Individual steps use three digits:

002 = Phase 0, step 02
102 = Phase 1, step 02
212 = Phase 2, step 12
213 = Phase 2, step 13
214 = Phase 2, step 14
300 = Phase 3, step 00
400 = Phase 4, step 00

Examples:

make phase 002   # build the forge disk
make phase 003   # boot the forge VM
make phase 101   # build onix-branding
make phase 213   # stage the first musl systemd userspace payload
make phase 214   # stage the first kernel module/kmod payload
make phase 212   # run the ONIX boot probe
make phase 300   # document deferred kernel ownership
make phase 400   # start booted-base userspace planning

Phase 3 is intentionally reserved for ONIX-owned kernel/initramfs/modules work. For now ONIX keeps using the borrowed Alpine virt kernel payload proved in Phase 2, and Phase 4 continues with booted userspace work.

Some phases can be watched interactively:

ATTACHED=1 make phase 212
make phase 212 ATTACHED=1

For Phase 212, attached mode uses the serial console in the current terminal by default. GUI/VNC only starts when explicitly requested:

ONIX_BOOT_PROBE_DISPLAY=gtk ATTACHED=1 make phase 212
ONIX_BOOT_PROBE_DISPLAY=vnc ATTACHED=1 make phase 212

Book commands

Build the book:

make book

Serve the book locally:

make book-serve

The generated HTML goes under:

book/html/

That directory is generated output and is not committed.

ONIX — Architecture & Build Plan

An atomic, moss-managed machine with a persistent Nix toolbox — built from scratch on musl. ONIX is the hard machine layer everything else sits on. The moss-managed machine plane is the foundation; the Nix toolbox is the living soil on top. It borrows AerynOS’s tooling (moss, boulder, the .stone format) and its naming world — you’re extending the geology, not breaking it.

Core rule: moss controls the machine. Nix controls the toolbox.

Direction change (2026-07). ONIX does not consume AerynOS’s ISO or its glibc package repos. Their live image is desktop-first (GNOME) and their whole volatile repo is glibc; neither fits. ONIX keeps only the toolingmoss (atomic package/state manager) and boulder (the .stone builder) — and bootstraps its own smallest-possible base on musl, from scratch. Alpine is the throwaway forge: a tiny musl host where moss+boulder are built and the first stones are cut. The endgame is our own musl distro, managed by moss, with Nix supplying the (glibc) software long tail on top.

Naming rule: public branding is always ONIX; machine IDs and package names use onix.


0. Identity & naming conventions

Locked in up front so every later artifact is consistent.

ThingNameNotes
Distro nameONIXPretty name
Machine-readable IDonixLowercase everywhere machine-readable
C librarymuslNon-negotiable; the whole base is musl. Nix apps bring their own glibc (see §3)
Magic number6649“ONIX” on a phone keypad — nixbld GID, VM MAC suffix, port offsets
Git orgonix-osRepos: image, recipes (musl .stone recipes), nix-integration, docs
Package namespaceonix-*All custom .stone packages: onix-base, onix-nix-integration, onix-cli, onix-branding, onix-desktop
Moss repoonixmoss repo add onix <url> — the only repo; there is no upstream repo beneath it
Upstream toolingos-toolsgithub.com/AerynOS/os-tools — moss + boulder, pinned. The one external dependency
CLI wrapperonixThin wrapper over moss + nix (see §6). onix update, onix rollback, onix status
Boot entry tokenonixBLS Type #1 entries: onix-<txid>.conf
Volume labelsONIX-ESP, ONIX-BOOT, onix-root, ONIX-PERSIST
ForgeAlpine musl VM, hostname quarryThrowaway bootstrap host — builds moss+boulder and the first stones.

/usr/lib/os-release (shipped by onix-branding):

NAME="ONIX"
ID=onix
PRETTY_NAME="ONIX (atomic musl base + Nix toolbox)"
VERSION_ID=rolling
BUILD_ID=<txid injected at image build>
HOME_URL="https://onix-os.com"
ANSI_COLOR="38;2;140;120;100"

No ID_LIKE: ONIX is not a downstream of any distro’s package set — it shares AerynOS’s tooling, not its packages, and its libc (musl) diverges from AerynOS (glibc). It stands on its own foundation.


1. Architecture overview

Two planes with a hard ownership contract:

┌─────────────────────────────────────────────────────────┐
│  USER TOOLBOX PLANE (persistent, user-driven)           │
│  /nix  ·  nix-daemon  ·  per-user profiles              │
│  nix shell / develop / profile · home-manager (opt)     │
│  GUI leaf apps, dev environments, the long tail         │
│  (nixpkgs is glibc — apps are self-contained, run on    │
│   the musl base because they carry their own libc)      │
├─────────────────────────────────────────────────────────┤
│  INTEGRATION SEAM (owned by onix-* stone packages)      │
│  /etc/nix/nix.conf · nixbld sysusers · nix-daemon unit  │
│  /run/opengl-driver · XDG/fontconfig/locale glue        │
├─────────────────────────────────────────────────────────┤
│  MACHINE PLANE (moss-owned, transactional, atomic)      │
│  MUSL base · /usr (stateless, renameat2-swapped)        │
│  /.moss store · kernel + initrd + BLS entries (blsforme)│
│  init, udev, Mesa/DRM, PipeWire, portals, compositor    │
│  — all built from our own musl .stone recipes           │
└─────────────────────────────────────────────────────────┘

Ownership contract (the constitution)

SurfaceOwnerNever touched by
/usr, kernel, initrd, boot entriesmossNix
/.moss content store & fstx historymossNix
Mesa/libdrm/firmware, compositor, D-Bus, PipeWire, portalsmoss (base packages, musl-built)Nix (system-wide)
/etc baseline defaultsmoss via /usr/share/defaultsNix
/etc/nix/*onix-nix-integration stone seeds it; nix daemon reads itmanual drift
/nix, /nix/store, /nix/varNixmoss
Per-user profiles, dev shells, user servicesNixmoss
setuid/setcap binaries, kernel modulesmoss onlyNix — hard rule

The single most important consequence: a moss rollback and a Nix profile rollback are independent operations that must never corrupt each other. Phase 5 validation exists specifically to prove this.


2. Bootstrapping the machine plane

This is the section the pivot rewrote most. There is no upstream to consume — the base is built from zero on musl, with boulder.

2.1 The forge (Alpine musl bootstrap host)

You cannot build .stone packages without moss + boulder, and you can’t run them meaningfully without a musl host to build in. Alpine is that host:

  1. Build a minimal Alpine/musl VM from scratch — from the 3.7 MB minirootfs tarball, not their ISO — into a bootable disk (vm/ in this repo already does this). Hostname quarry.
  2. Build moss + boulder from github.com/AerynOS/os-tools (just get-started). They’re ordinary Rust binaries; they build and run on musl. boulder explicitly supports non-AerynOS hosts (--data-dir, --config-dir, --moss-root).
  3. Alpine is scaffolding, thrown away in the final design. During the current Phase 2 boot proof, ONIX temporarily borrows Alpine’s virt kernel/initramfs/module payload so image assembly and systemd-on-musl can be proved before kernel ownership becomes its own project. Phase 3 exists to remove that shortcut later. The forge still provides the musl toolchain and userns environment used to run boulder.

2.2 Bootstrap the musl base as .stone packages

No AerynOS recipe is musl — a musl base is a genuine bootstrap, and that is the point of the project.

  1. Author .stone recipes (stone.yaml) for the core musl userland: musl, a compiler toolchain, the base command set, the handful of packages onix-base needs. Use Alpine’s APKBUILDs as a reference for the musl-specific patches; keep the base set short — the long tail is Nix’s job (§3).
    • Coreutils strategy (Rust-first, matches the moss/boulder ethos): ship busybox as the working base first — it is small, musl-native, and already proven in the forge. Then bring in uutils/coreutils (Rust) alongside it. Once uutils is proven, the plain tool names (ls, cp, …) resolve to uutils, and busybox stays reachable but not default — invoked as busybox <tool>, not removed. Sequenced for Phase 1, after the forge builds stones.
  2. boulder supports bootstrapping explicitly: a shared control file can disable tests during initial rounds, then be removed for final builds with tests re-enabled.
  3. Stand up the onix moss repo — a static, locally-served index of your boulder-built stones. Start as file:///var/lib/onix/repo/ on the forge; graduate to static HTTPS (any dumb file server; no vessel needed at this scale). It is the only repo — nothing sits beneath it.
  4. Pin os-tools. The one external dependency is the tooling. The forge records this as OS_TOOLS_REF in vm/phase0/config.sh; an onix mirror habit later hedges against upstream churn.

2.3 Image building

Assemble a bootable image from the musl base stones (an onix-os/image repo):

  • moss-install onix-base + onix-branding (os-release above) into a fresh root: kernel, initrd tooling, init, udev, dbus, a network stack, busybox/coreutils, filesystem tools (xfsprogs, dosfstools), moss itself.
  • Keep a QEMU/OVMF smoke-test — that’s CI from day one (the vm/ harness generalizes to boot the built image).
  • Every image build records: os-tools commit, onix repo commit, resulting fstx ID. That triple is the reproducible build pin for a rolling personal distro.

2.4 Boot artifacts

Target BLS Type #1 entries exactly as moss/blsforme do: entries named onix-<txid>.conf, kernel+initrd promoted to $BOOT (XBOOTLDR), transaction ID on the kernel cmdline, old entries pruned by moss. blsforme is a bootloader-spec tool and does not itself require systemd as PID 1 — but the surrounding glue (sysusers/tmpfiles) is systemd-flavored, which forces the init decision below. UKI + Secure Boot is later future work.

Init decision. ONIX uses systemd as PID 1 plus systemd-boot/BLS rather than GRUB for the real image. Phase 209 showed that the pinned Nix universe contains a musl-targeted pkgsMusl.systemd, so the current project path is systemd-on-musl. Phase 0’s Alpine OpenRC/GRUB setup remains throwaway forge scaffolding only.


3. The Nix plane and the integration seam

3.1 Do not run the Nix installer

Neither the official curl installer nor the Determinate installer fits a stateless-/usr system: they mutate /etc, drop init units imperatively, and useradd. That’s exactly the drift ONIX exists to avoid. (They also assume glibc/systemd — doubly wrong here.)

Instead, the linchpin deliverable is one package:

3.2 onix-nix-integration (.stone)

Ships everything Nix needs, declaratively, through the machine plane:

ComponentPathContent
Nix binariesvia /nixOne-time seed /nix from a Nix release; Nix then upgrades itself. The stone ships integration, not Nix — keeps moss out of tracking Nix releases. Default profile bin on PATH.
Build userssysusers.d (or boot-script equiv. under init-(b))g nixbld 6649 + u nixbld1..32 -:nixbld (GID 6649 — “ONIX”). Materialized every boot; survives an ephemeral-/etc future.
Daemon unitnix-daemon.service + .socket (or native service)Ordering hardened: after /nix mount, ConditionPathIsDirectory=/nix/store.
Mount unitnix.mountOr generated from fstab — see §4.
Runtime dirstmpfiles.d (or equiv.)/nix/var/nix/daemon-socket, gcroots dirs, profiles/per-user.
System config/usr/share/defaults/nix/nix.conf/etc/nix/nix.conf overridesbuild-users-group = nixbld, sandbox = true, auto-optimise-store = true, allowed-users = @users, trusted-users = root, experimental-features = nix-command flakes.
Shell hooks/usr/share/defaults/etc/profile.d/onix-nix.shPATH, NIX_REMOTE=daemon default, XDG_DATA_DIRS, LOCALE_ARCHIVE guard, fontconfig include.

3.3 Graphics: /run/opengl-driver as a first-class feature

The classic failure mode of Nix GUI apps on foreign distros: they load nixpkgs’ Mesa, which mismatches the host kernel/DRM stack → llvmpipe or crashes. ONIX fixes this at the machine layer — the distro’s genuinely distinguishing feature:

  • onix-opengl-driver glue in onix-nix-integration populates /run/opengl-driver/lib at boot with symlinks into the moss-managed Mesa/libdrm/Vulkan ICDs under /usr.
  • Nixpkgs apps honor the NixOS /run/opengl-driver convention, so they pick up host-matched userspace drivers with zero per-app wrapping.
  • Because the symlink farm is rebuilt each boot from active /usr state, a moss rollback of the graphics stack re-coheres the Nix GUI world on next boot. No nixGL, no per-app hacks.
  • Same glue handles /run/opengl-driver/share/vulkan/icd.d and EGL vendor files.

musl-specific caveat (bigger than the original glibc-skew note). Our base Mesa is musl-built; nixpkgs GL apps are glibc. Nix apps carry their own glibc and most libraries from the store, so they run on a musl base — but the /run/opengl-driver seam is exactly where a musl-built Mesa meets a glibc GL app, and that boundary can break (symbol/ABI mismatch). Mitigation to carry forward: the opengl-driver bridge may need to expose a glibc-built Mesa variant for the Nix world (built as its own stone, or pulled from nixpkgs), rather than symlinking the musl base Mesa directly. Resolve in Phase 7; keep it in mind from Phase 5.

3.4 Optional: home-manager standalone

Fine from Phase 5 onward, standalone mode only — manages ~/.config and user services, never system state. Composes cleanly with the contract. Personal preference, not architecture.


4. Filesystem layout & persistence

4.1 Partitions (VM prototype; same shape on hardware)

PartitionFSSize (VM)LabelMount
ESPFAT32512 MiBONIX-ESP/efi
XBOOTLDRFAT322 GiBONIX-BOOT/boot
RootXFS40 GiB+onix-root/ (holds /.moss; bigger = more rollback states)
PersistXFS or ext4restONIX-PERSIST/persist
  • /nix = bind mount from /persist/nix (mount unit ordered before local-fs, nix-daemon after it as in §3.2). One persistence surface to snapshot/back up.
  • /home = bind from /persist/home.

(The forge disk is simpler — a plain GPT ESP + ext4 root; the table above is the ONIX image target.)

4.2 Persistence policy — Phase-appropriate

Phases 2–8: fully persistent /etc and /var. The /usr/share/defaults + /etc-overrides model bounds drift; ephemeral overlays add early-boot ordering complexity for marginal gain now. Add drift visibility instead: onix status diffs /etc against shipped defaults and reports every override.

Later optional, post-daily-driver: ephemeral /etc+/var overlays with an explicit allowlist. Must-persist set, documented now:

/etc/machine-id · /etc/ssh/ssh_host_* · /etc/nix/ · init/service state (/var/lib/…) · NetworkManager/bluetooth state · /var/log (your choice) · /home · /nix · /.moss


5. Phased roadmap

Each phase has an exit gate. Don’t advance until the gate passes — the gates are the real deliverable.

Phase 0 — The forge (musl tooling host)

Build a minimal Alpine/musl VM from the minirootfs (this repo’s vm/). Build moss + boulder from os-tools. Cut a trivial first .stone with boulder; install/remove it with moss into a test root; inspect moss state list. Gate: moss + boulder run on musl; you can boulder-build a hello-world .stone, moss-install it, roll the moss state back, and remove it — cleanly.

Current smoke-test command:

make doctor     # common health check
make phase 004  # once per fresh forge image: build moss + boulder
make phase 005  # build/check/extract/index/install/run onix-hello
make phase 006  # real moss state install/remove/rollback smoke test

Recipe gotcha learned in the forge: Boulder build directories inherit g+s. If a recipe creates /usr//usr/bin with the setgid bit still present, Boulder treats those directories as special and emits a /usr/ layout entry. Moss currently rejects that path during extract/install. For simple recipes that install directly into /usr/bin, clear setgid in install:

install     : |
    install -Dm00755 my-tool %(installroot)%(bindir)/my-tool
    chmod g-s %(installroot)/usr %(installroot)%(bindir)

Phase 1 — Bootstrap the musl base stones

Author stone.yaml recipes for the core musl userland (musl, toolchain, busybox/coreutils, essentials). Stand up the onix moss repo (file:// then static HTTPS). Keep the base set short. Gate: the onix repo carries a self-consistent base stone set; moss installs it into a fresh root that chroots and runs a shell + coreutils.

Phase 2 — First bootable ONIX image

Assemble and boot the first ONIX disk image in QEMU/OVMF. This phase proves the image layout, systemd-boot/BLS path, systemd-on-musl userspace, root filesystem, persistent partitions, and first module-loading payload. It temporarily borrows the Alpine forge’s virt kernel/initramfs/modules so the boot proof can move without starting kernel work too early. Gate: the image boots through systemd to multi-user mode; /boot, /efi, /persist, /home, and /nix mount; the boot log has no missing modprobe or module-compression errors.

Phase 3 — ONIX-owned kernel/initramfs/modules (reserved)

Replace the borrowed Alpine kernel payload with ONIX-owned kernel artifacts. This phase owns the kernel config, kernel build, module package, initramfs generation, and boot-entry/update policy. It is intentionally deferred while Phase 4 makes the already-booting image usable. Gate: kernel, initramfs, and modules are built and packaged by ONIX; their versions match; boot entries select coherent generations; rollback behavior is understood.

Phase 4 — Booted ONIX base userspace

Turn the Phase 2 boot proof into a usable base system while still using the borrowed kernel payload. Materialize live /etc from /usr/share/defaults; define users/groups/login shell policy; prove serial login; add minimal networking and remote inspection. Gate: a booted ONIX image can be logged into and inspected intentionally; base identity, fstab, users, shells, hostname, and early services are owned by ONIX policy rather than ad-hoc image glue.

Phase 5 — Nix plane (the critical phase)

Ship onix-nix-integration; seed /persist/nix from a Nix release tarball; verify daemon-mode multi-user builds, nix shell/develop/profile install. Confirm glibc nixpkgs apps run on the musl base. Gate — the composition matrix, all green:

TestExpected
nix profile install nixpkgs#ripgrep → reboottool still on PATH, runs on musl base
Install Nix tool → moss rollback → rebootmachine state rolled back, Nix tool untouched
moss update → nix profile rollbackboth planes at chosen generations, no interference
moss state prune + nix store gc back-to-backno cross-corruption, both stores intact
Kill nix-daemon mid-build → restartstore consistent

Phase 6 — Recipe set & overlay matures

boulder workflow for the growing musl recipe set (onix-cli, base additions — keep short; the long tail is Nix). Static HTTPS hosting; onix mirror snapshot habit (mirror os-tools + your stones). Gate: clean-room rebuild from the pinned snapshot triple (§2.3) boots identically.

Phase 7 — Desktop

Pick one desktop (start minimal — a Wayland compositor). All of: compositor, Mesa, PipeWire + WirePlumber, xdg-desktop-portal backend, fonts — from the machine plane (musl-built). Enable the /run/opengl-driver glue. Gate: a Nix-installed GL app renders hardware-accelerated with no wrapper (this is where the musl-Mesa vs glibc-app seam of §3.3 gets solved); portals work from a Nix app; ~/.nix-profile desktop entries appear; then roll back a Mesa update and confirm the Nix app still renders on the previous stack after reboot.

Phase 8 — Hardware

AMD/Intel graphics strongly preferred (NVIDIA complicates both musl and the /run/opengl-driver story). Dedicated drive — no same-disk dual boot at this maturity. Migrate /persist by restore-from-backup, not disk surgery. Gate: two weeks of daily driving with at least one rollback drill and one restore-from-backup drill of /persist.

Later / optional

Init decision revisited & musl recipes contributed upstream · UKI + Secure Boot · disk encryption (initrd unlock) · ephemeral /etc allowlist mode · onix from shell script to small Rust binary.


6. The onix CLI

Thin, honest wrapper — it must never hide which plane a command touches:

onix update      # moss sync + report pending reboot
onix rollback    # moss state activation to previous, prints boot instructions
onix status      # active fstx, boot entry, /etc drift vs defaults, nix-daemon health,
                 # /run/opengl-driver coherence check
onix gc          # moss state prune + nix store gc, sequenced, with guardrails
onix mirror      # snapshot os-tools + depended-on stones to /persist/mirror
onix doctor      # runs the Phase 5 composition matrix as an automated self-test

Ships as onix-cli from the onix repo. Start as ~200 lines of shell; rewrite in Rust when it stops being embarrassing to. (onix solid as an alias for a fully-green onix doctor run is optional but encouraged.)


7. Risks & maintenance posture

RiskExposureMitigation
From-scratch musl bootstrap is a large effortHigh — this is the whole project nowPhase gates; Alpine forge does the heavy lifting for the toolchain; keep the base tiny — Nix covers the long tail so the base recipe set stays small
No musl .stone recipes exist upstreamHighAuthor/port them, using Alpine APKBUILDs as the musl-patch reference; ruthless minimalism on the base set
systemd-on-musl maintenanceHigh / structuralONIX keeps the systemd-on-musl path while it remains feasible; package it intentionally as onix-systemd and test PID 1, udev, tmpfiles, sysusers, and service startup in QEMU. §2.4
moss/boulder are alpha & glibc-testedMediumThey’re open Rust you can read/patch; pin os-tools; fix any glibc assumptions and upstream them
glibc skew: musl base Mesa vs glibc Nix appsMedium, intermittentNix apps are self-contained so most run; concentrate the fix at the /run/opengl-driver seam — possibly a glibc Mesa variant for the Nix world (§3.3)
Branding driftMedium during early developmentKeep public names as ONIX or onix only; grep tracked docs/scripts before commits
NVIDIAHigh if chosenDon’t. AMD/Intel for this project
Solo-maintainer burdenReal, and higher than a derivative would beBudget honestly; keep base scope minimal; if it trends high, cut base scope and lean harder on Nix
Data lossTransactional /usr protects nothing you care about/persist (= /home + /nix + configs) gets real backups — borg/restic to external target — from Phase 5, not Phase 8. moss rollback is not a backup

8. What ONIX is, in one paragraph

A small, auditable, atomic base you build yourself on musl — using AerynOS’s excellent tooling (moss + boulder) but none of its packages — that you can always roll back; a huge optional software universe on top through persistent multi-user Nix (glibc apps riding on a musl base, carrying their own libc); and a machine-layer /run/opengl-driver bridge that makes Nix GUI apps first-class in a way almost no foreign distro manages. Alpine is the forge where the tooling is built and the first stones are cut, then discarded. The machine plane is the foundation; everything you actually live in grows on top of it — and the ground never shifts under you without your say-so. The machine manager controls the machine. Nix controls the toolbox.

ONIX recipes

This tree holds ONIX .stone recipes.

Phase 0 used a toy recipe generated inside the forge (onix-hello) only to prove Boulder and Moss work.

Phase 1 starts real ONIX-owned packages here.

Current Phase 1 packages:

onix-branding     identity metadata; Moss generates os-release from it
onix-filesystem   filesystem layout policy and default templates

Build them through the learning flow:

  • make phase 101
  • make phase 102

Phase 0 overview — forge VM and first .stone

Phase 0 is not the ONIX operating system yet. It is the forge: a temporary Alpine/musl VM where we build and test the package tooling before trying to build ONIX itself.

The Phase 0 proof chain is:

Alpine minirootfs
  -> bootable forge VM
  -> moss + boulder
  -> first tiny .stone
  -> real Moss state install/remove/rollback

Why Phase 0 exists

ONIX wants to be musl-based and Moss-managed. To build .stone packages we need moss and boulder running on a musl machine. Alpine gives us a small temporary musl environment. It is scaffolding, not the final distro.

Steps

Running:

make phase 0

runs the whole Phase 0 family.

Phase 000 — validate

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 000
Underlying make target/scriptmake -C vm/phase0 check
Runs onhost
Main proof/artifactPhase 0 scripts parse, cached rootfs is valid if present, and QEMU command construction still works.
make phase 000

This runs the cheap validation lane. It does not boot the VM and does not build a disk. It is the “are my scripts still sane?” step.

Under the hood it checks shell syntax for the Phase 0 scripts and confirms the QEMU command can be assembled.

Phase 001 — passwordless disk builder

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 001
Underlying make target/scriptvm/phase0/install-sudoers.sh --ensure
Runs onhost with sudo only if the rule is missing/stale
Main proof/artifactRootful image builders can run without repeated sudo prompts.
make phase 001

Disk building needs operations normal users cannot do:

  • losetup
  • mount
  • mkfs
  • chroot

So build-disk.sh re-execs itself with sudo. This phase ensures a sudoers drop-in allowing only this repo’s Phase 0 disk builder to run without another password prompt.

make doctor runs this ensure step too. The important detail: the ensure step first probes the existing rule with sudo -n ./build-disk.sh --sudoers-check. sudo -n means “never ask for a password”. So if the rule is already correct, there is no sudo prompt. If the rule is missing or stale, then the installer prompts once and writes /etc/sudoers.d/onix-forge.

Tradeoff: because the script is writable by your user, this is effectively passwordless root for that script path. That is why it is explicit and separate.

If the script path moves, rerun make doctor or this phase so sudoers points at the new path.

Phase 002 — build the forge disk

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 002
Underlying make target/scriptvm/phase0/fetch-rootfs.sh + vm/phase0/build-disk.sh
Runs onhost, with rootful loop/mount/chroot work
Main proof/artifactCreates the bootable forge disk at vm/state/quarry.raw.
make phase 002

This creates the bootable forge disk:

vm/downloads/alpine-minirootfs-*.tar.gz
        |
        v
vm/state/quarry.raw

The disk layout is intentionally simple:

ONIX-ESP    FAT32 EFI system partition
onix-root   ext4 root filesystem

During the build, the script:

  1. creates a sparse raw disk
  2. attaches it with losetup
  3. partitions it
  4. formats the ESP and root partition
  5. extracts Alpine minirootfs
  6. enters a chroot
  7. installs kernel, OpenRC, SSH, GRUB, Rust/build tools
  8. creates the mason build user
  9. exports kernel/initramfs for direct boot fallback

OpenRC and GRUB are only forge scaffolding. The real ONIX target can still use systemd/systemd-boot later if we choose that route.

Phase 003 — boot the forge

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 003
Underlying make target/scriptvm/phase0/launch.sh
Runs onhost QEMU process, guest serial/login console
Main proof/artifactBoots the forge VM named quarry.
make phase 003

This starts QEMU using the disk from Phase 002.

Expected login:

username: mason
password: onix

SSH is forwarded from host port 6649 to guest port 22.

The VM hostname is:

quarry

When you run make phase 003 directly, QEMU stays in the foreground so you can watch and interact with the console.

When you run the whole family with make phase 0, phase 003 uses a batch-safe boot path instead:

launch QEMU in the background
tail vm/state/quarry.serial.log so you still see boot logs
wait until SSH on 127.0.0.1:6649 is ready
continue automatically to phase 004

That means the full batch does not get stuck at the Alpine login prompt.

Phase 004 — provision tools

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 004
Underlying make target/scriptvm/phase0/ssh.sh mason ./provision.sh
Runs onguest over SSH
Main proof/artifactBuilds moss and boulder inside the forge.
make phase 004

This SSHes into the running forge and builds:

  • moss
  • boulder

from the pinned os-tools commit in config.sh.

Result inside the guest:

/home/mason/.local/bin/moss
/home/mason/.local/bin/boulder

Conceptually:

  • moss manages installed package states atomically
  • boulder builds .stone packages from stone.yaml

Phase 005 — first .stone

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 005
Underlying make target/scriptvm/phase0/build-hello-stone.sh
Runs onguest over SSH
Main proof/artifactBuilds, checks, indexes, installs, and runs the tiny onix-hello .stone.
make phase 005

This builds a deliberately tiny package called onix-hello.

It creates a local source archive in the guest, writes a stone.yaml, builds a .stone, checks it, extracts it, indexes it into a local repo, installs it into a throwaway target root, and runs:

/usr/bin/onix-hello

Expected output:

hello from onix forge

This proves the package build pipeline works:

source tarball -> stone.yaml -> boulder -> .stone -> moss inspect/extract/install

Important recipe gotcha learned here:

install     : |
    install -Dm00755 onix-hello %(installroot)%(bindir)/onix-hello
    chmod g-s %(installroot)/usr %(installroot)%(bindir)

Boulder build directories inherit g+s. If /usr keeps that bit, Boulder can emit a /usr/ layout entry that Moss rejects during extract/install.

Phase 006 — real Moss state smoke test

At a glance

FieldValue
Phase familyPhase 0 — forge
Run commandmake phase 006
Underlying make target/scriptvm/phase0/state-smoke.sh
Runs onguest over SSH
Main proof/artifactProves real Moss install/remove/rollback state transitions in a disposable root.
make phase 006

Phase 005 uses moss install --to, which blits files into a target directory but does not create a real Moss state transaction.

Phase 006 uses:

moss -D <root> install onix-hello

That creates real state history under a disposable root.

Expected proof:

install onix-hello -> State #1
remove onix-hello  -> State #2
activate State #1     -> rollback works

This is the final Phase 0 gate because atomic state management is the whole reason we are using Moss.

Files in this phase

FilePurpose
MakefilePhase 0 target implementation
config.shshared settings: Alpine pin, VM name, disk path, ports, os-tools ref
fetch-rootfs.shdownloads and verifies Alpine minirootfs
build-disk.shbuilds the bootable forge disk
chroot-setup.shruns inside the new rootfs while building the disk
launch.shstarts QEMU
ssh.shSSH helper for the running forge
provision.shbuilds moss and boulder inside the VM
build-hello-stone.shcreates and verifies the first .stone
state-smoke.shproves real Moss state install/remove/rollback
clean.shremoves generated forge disk/boot artifacts
install-sudoers.shinstalls/uninstalls the passwordless disk-builder sudoers rule

When Phase 0 is complete

Phase 0 is complete when all of these have worked:

  • make doctor
  • make phase 002
  • make phase 003
  • make phase 004
  • make phase 005
  • make phase 006

At that point we know:

  1. we can boot a musl forge,
  2. we can build the AerynOS tools on musl,
  3. we can build a valid .stone,
  4. we can create a local Moss repo,
  5. we can install/remove packages with real Moss states,
  6. we can roll back to an older state.

Only after this does Phase 1 make sense: real ONIX recipe/repo bootstrap.

Phase 1 overview — first real ONIX stones

Phase 1 turns the Phase 0 tooling proof into real ONIX package artifacts.

We start deliberately small:

onix-branding
onix-filesystem

These packages establish identity, default filesystem policy, and the first publishable repository shape.

Why Phase 1 exists

Phase 0 proved we can build a toy package. Phase 1 proves ONIX can build real package payloads, compose them, index them into a Moss repo, export that repo to the host, and preview a future static package repository.

Steps

Running:

make phase 1

runs the whole Phase 1 family.

Phase 100 — forge readiness

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 100
Underlying make target/scriptvm/phase1 ready target
Runs onguest over SSH
Main proof/artifactConfirms the forge has working moss and boulder before real recipe work.

Checks that the running forge is reachable and that these tools exist inside it:

  • moss
  • boulder

If this fails, Phase 0 is not ready. Run:

make phase 003
make phase 004

Phase 101 — build onix-branding

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 101
Underlying make target/scriptvm/phase1/build-branding-stone.sh
Runs onguest over SSH
Main proof/artifactBuilds and verifies the onix-branding stone.

Builds the recipe at:

recipes/onix-branding/stone.yaml

inside the forge, then verifies:

  • the .stone passes moss inspect --check
  • /usr/lib/os-info.json exists
  • Moss generates /usr/lib/os-release from that metadata during install
  • /usr/share/onix/branding/logo.txt contains the plain terminal ONIX logo
  • /usr/share/onix/branding/logo.ansi contains the ANSI-colored terminal logo
  • default /etc text lives under /usr/share/defaults/etc/
  • installing into a disposable target root works

Boulder currently ignores non-/usr payload files in this layout. That means onix-branding ships the canonical input metadata at /usr/lib/os-info.json. Moss uses that to generate /usr/lib/os-release during install. Later image assembly or first-boot glue creates the compatibility symlink:

/etc/os-release -> ../usr/lib/os-release

The branding colors come from the real ONIX logo assets:

orange: #e7590f  /  38;2;231;89;15
blue:   #4f6e91  /  38;2;79;110;145

ANSI_COLOR in os-release uses the ONIX blue. The default MOTD includes the terminal logo with orange on the left and blue on the right.

Why defaults under /usr/share/defaults/etc?

The final ONIX contract is:

moss owns the machine plane
local admin/user changes live outside the immutable package payload

So for mutable configuration text like issue and motd, the package ships defaults under:

/usr/share/defaults/etc/

Later boot/install glue can copy or merge those into /etc if needed. The package still ships the canonical /usr/lib/os-info.json; Moss generates /usr/lib/os-release, and image assembly creates the standard /etc/os-release compatibility symlink outside the .stone.

Phase 102 — build onix-filesystem

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 102
Underlying make target/scriptvm/phase1/build-filesystem-stone.sh
Runs onguest over SSH
Main proof/artifactBuilds onix-filesystem and proves it composes with onix-branding.

Builds the recipe at:

recipes/onix-filesystem/stone.yaml

This package does not own live /etc, /var, /run, /dev, /proc, or /sys. Instead, it installs policy and templates under /usr:

/usr/share/onix/filesystem-layout.md
/usr/share/defaults/etc/fstab
/usr/share/defaults/etc/profile.d/onix-path.sh

The Phase 102 test installs both onix-branding and onix-filesystem into the same disposable target root, so we prove the first two real ONIX stones compose.

Phase 103 — assemble first named local ONIX repo

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 103
Underlying make target/scriptvm/phase1/build-local-repo.sh
Runs onguest over SSH
Main proof/artifactCreates the first named local ONIX Moss repo and installs packages from it by name.

Phase 101 and 102 prove individual stones work. Phase 103 proves the next layer: those stones can live in a named repository.

It collects:

~/stone-lab/onix-branding/out/*.stone
~/stone-lab/onix-filesystem/out/*.stone

then creates:

~/stone-lab/onix-repo/repo/stone.index

and adds that index to a disposable Moss root as repo name onix-local.

The important proof is that the install happens by package name:

moss ... install --to <target> onix-branding onix-filesystem

So this is the bridge from “we have loose package files” to “ONIX has the beginning of a package repository.”

Phase 104 — prepare publishable ONIX repo layout

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 104
Underlying make target/scriptvm/phase1/build-publishable-repo.sh
Runs onguest over SSH
Main proof/artifactAssembles the first static-host-style ONIX repo layout.

Phase 103 proves a named repo works locally. Phase 104 reshapes that idea into a directory layout we could later upload to static hosting.

It creates:

~/stone-lab/onix-publish/
  README.txt
  repo.json
  unstable/x86_64/
    stone.index
    SHA256SUMS
    onix-branding-*.stone
    onix-filesystem-*.stone

repo.json records the public identity:

homepage: https://onix-os.com
source:   https://github.com/onix-os
hint:     https://repo.onix-os.com/unstable/x86_64/stone.index

This phase does not upload anything. It only proves the publish-style layout works by adding the local stone.index as repo onix-unstable and installing onix-branding + onix-filesystem from it.

Phase 105 — export publishable repo to the host

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 105
Underlying make target/scriptvm/phase1/export-publishable-repo.sh
Runs onhost plus guest over SSH
Main proof/artifactCopies the publishable repo artifact to artifacts/onix-publish/.

Phase 104 creates the publishable repo inside the forge VM. Phase 105 copies it back to the host:

forge: ~/stone-lab/onix-publish/
host:  artifacts/onix-publish/

The host artifact is gitignored because it contains generated .stone package files and checksums.

After this phase, the important host files are:

artifacts/onix-publish/repo.json
artifacts/onix-publish/README.txt
artifacts/onix-publish/unstable/x86_64/stone.index
artifacts/onix-publish/unstable/x86_64/SHA256SUMS
artifacts/onix-publish/unstable/x86_64/*.stone

This still does not publish anything. It gives us a local host-side artifact that a later phase can upload to repo.onix-os.com or another static host.

Phase 106 — verify exported host artifact

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 106
Underlying make target/scriptvm/phase1/verify-exported-repo.sh
Runs onhost
Main proof/artifactVerifies the exported repo artifact, checksums, metadata, and gitignore protection.

Phase 106 is host-only. It does not SSH into the forge VM.

It verifies:

  • artifacts/onix-publish/repo.json exists and names ONIX correctly
  • homepage is https://onix-os.com
  • source is https://github.com/onix-os
  • future repo hint is https://repo.onix-os.com/unstable/x86_64/stone.index
  • exactly one onix-branding stone exists
  • exactly one onix-filesystem stone exists
  • SHA256SUMS validates
  • no Moss test state (.moss, moss-root, moss-cache, install-target) leaked into the artifact
  • artifacts/ is gitignored

This gives us a clean gate before any future upload/publish phase.

Phase 107 — verify no-upload publishing plan

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 107
Underlying make target/scriptvm/phase1/prepare-publish-plan.sh
Runs onhost
Main proof/artifactVerifies the no-upload publishing contract in this page.

Phase 107 is also host-only. It does not SSH into the forge VM and does not publish anything.

It verifies two things:

  1. the exported artifact still passes Phase 106 checks
  2. this book page contains the current publication contract

The publication contract records:

  • homepage: https://onix-os.com
  • source: https://github.com/onix-os
  • future repo root: https://repo.onix-os.com
  • future Moss index: https://repo.onix-os.com/unstable/x86_64/stone.index
  • local artifact source: artifacts/onix-publish/
  • the rule that no current phase uploads or changes DNS

This gives us a safe stopping point before any future real hosting work.

Phase 107 publication contract

This is the safe publication plan for the package repo artifact produced by Phase 1.

It is intentionally a plan, not an upload script. No phase currently changes DNS, pushes to a server, or publishes packages to the internet.

Canonical project locations:

homepage: https://onix-os.com
source:   https://github.com/onix-os
repo:     https://repo.onix-os.com

The host-side artifact is produced by:

make phase 105

and verified by:

make phase 106

The clean local publish root is:

artifacts/onix-publish/

Expected files:

artifacts/onix-publish/README.txt
artifacts/onix-publish/repo.json
artifacts/onix-publish/unstable/x86_64/SHA256SUMS
artifacts/onix-publish/unstable/x86_64/stone.index
artifacts/onix-publish/unstable/x86_64/onix-branding-*.stone
artifacts/onix-publish/unstable/x86_64/onix-filesystem-*.stone

The future public Moss index URL is:

https://repo.onix-os.com/unstable/x86_64/stone.index

Before any real upload, run:

make phase 104
make phase 105
make phase 106

Those checks must prove:

  • SHA256SUMS validates
  • the artifact contains only publish files
  • no .moss, moss-root, moss-cache, or install-target directories leaked
  • repo.json says homepage is https://onix-os.com
  • repo.json says source is https://github.com/onix-os
  • repo.json hints https://repo.onix-os.com/unstable/x86_64/stone.index

Any hosting target must serve this directory tree byte-for-byte:

repo root/
  unstable/
    x86_64/
      stone.index
      SHA256SUMS
      *.stone

The host must allow direct HTTP GET for:

/unstable/x86_64/stone.index
/unstable/x86_64/SHA256SUMS
/unstable/x86_64/*.stone

The repo can be hosted on any static file host. Candidate paths:

  • a dedicated static host behind repo.onix-os.com
  • GitHub Pages for a future repo such as github.com/onix-os/repo
  • an object bucket or VPS static directory

When ready, create DNS for:

repo.onix-os.com

pointing at the chosen static host.

Do not point DNS at a host until the uploaded tree can serve:

https://repo.onix-os.com/unstable/x86_64/stone.index

A future phase may add a real upload command, but it must be explicit and separate from the build/verify phases.

Good shape:

make phase 108   # dry-run upload / publish preview; no network, no upload
make phase 109   # real upload, only after explicit confirmation

The real upload phase should:

  1. run make phase 106
  2. upload artifacts/onix-publish/ to the chosen static host
  3. fetch the public stone.index
  4. compare its checksum with the local stone.index
  5. fetch public SHA256SUMS
  6. verify every listed file is reachable
  7. print the exact repo add command users will run later

Once the repo is live, the user-facing repo command shape should be:

moss repo add onix-unstable https://repo.onix-os.com/unstable/x86_64/stone.index -c "ONIX unstable"
moss repo update

Do not publish this as an installation instruction until the public URL is live and verified.

Phase 108 — preview publication without upload

At a glance

FieldValue
Phase familyPhase 1 — first stones
Run commandmake phase 108
Underlying make target/scriptvm/phase1/publish-dry-run.sh
Runs onhost
Main proof/artifactPrints the future publication mapping without uploading or touching the network.

Phase 108 is still host-only and still safe. It does not upload anything, does not contact the network, and does not change DNS.

It verifies the Phase 107 plan, then prints:

  • local artifact root
  • future public root
  • every file that would be published
  • the future public URL for every file
  • critical URLs to check after a real upload
  • the rsync/curl commands that a future real publish phase might run, but refuses to run them
  • the future user-facing moss repo add command

You can optionally preview a concrete upload destination without using it:

ONIX_REPO_UPLOAD_TARGET='user@host:/srv/repo.onix-os.com' make phase 108

It will print the target, but still not upload.

Phase 2 overview — first bootable ONIX image

Phase 2 takes the ONIX package repo artifact from Phase 1 and starts turning it into a bootable disk image.

Phase 2 is a boot proof, not the final kernel ownership story. It intentionally uses the Alpine forge’s virt kernel/initramfs/module payload so we can prove the image layout and systemd-on-musl userspace before spending a full phase on kernel building.

The Phase 2 learning arc is:

exported package repo
  -> host-side moss
  -> root tree
  -> disk image
  -> systemd-boot skeleton
  -> kernel/initramfs payload
  -> first musl systemd userspace payload
  -> first kernel module/kmod payload
  -> first QEMU boot probe

The borrowed payload boundary is explicit:

Phase 2: boot with borrowed Alpine kernel payload
Phase 3: later replace that with ONIX-owned kernel/initramfs/modules
Phase 4: continue now with booted ONIX base userspace

About make phase 2

make phase 2 runs the canonical host-native Phase 2 path:

200 -> 202 -> 203 -> 204 -> 205 -> 206 -> 207 -> 208 -> 209 -> 210 -> 211 -> 213 -> 214 -> 212

It intentionally skips Phase 201 because Phase 201 is the older bridge step that uses the forge VM over SSH. Phase 203 is the normal host-native root-tree assembly path.

Steps

Running:

make phase 2

runs the canonical host-native Phase 2 path.

After this passes, the immediate next implementation lane is Phase 4:

make phase 400

Phase 3 is reserved for later kernel ownership work:

make phase 300

Phase 200 — image assembly readiness

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 200
Underlying make target/scriptvm/phase2/check-readiness.sh
Runs onhost
Main proof/artifactConfirms Phase 2 has repo artifacts and image tools available.

Phase 200 is host-only. It does not boot QEMU, does not SSH into the forge, and does not build an image yet.

It verifies:

  • Phase 1 exported repo artifact exists at artifacts/onix-publish/
  • SHA256SUMS validates through the Phase 1 verifier
  • onix-branding and onix-filesystem stones exist
  • no forbidden brand spelling exists in tracked project areas
  • host/dev-shell has the tools needed for image assembly

Important future image tools include:

sgdisk
partprobe
losetup
mkfs.fat
mkfs.ext4
mkfs.xfs
mount
umount
truncate
tar
sha256sum

mkfs.xfs matters because Phase 1’s filesystem template already describes the future ONIX root and persist filesystems as XFS:

LABEL=onix-root     /         xfs
LABEL=ONIX-PERSIST  /persist  xfs

If make phase 200 says mkfs.xfs is missing, re-enter the dev shell:

direnv reload

The flake includes xfsprogs, so the command should appear after the updated environment loads.

Phase 201 — assemble the first ONIX root tree

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 201
Underlying make target/scriptvm/phase2/build-root-tree.sh
Runs onhost plus guest over SSH
Main proof/artifactAssembles artifacts/onix-root-tree/ through the older forge bridge path.

Phase 201 is the first big conceptual jump in Phase 2.

Before this point, we proved packages in disposable Moss test targets. Phase 201 starts acting like an image builder:

exported repo artifact -> package install -> root filesystem tree

It still does not create a disk image. It does not partition anything. It does not mount anything. It does not boot. The output is just a directory tree on the host:

artifacts/onix-root-tree/

That directory is gitignored because it is generated build output.

Why Phase 201 uses the forge

When Phase 201 was introduced, the host did not yet have moss.

Also, .stone files are not tarballs. They are Moss stone containers. That means Phase 201 should not pretend the host can unpack them with tar.

So the Phase 201 flow is:

host artifacts/onix-publish/
   │
   │  stream to forge
   ▼
forge moss install --to root-tree
   │
   │  materialize image-owned /etc glue
   ▼
forge root-tree/
   │
   │  tar stream back to host
   ▼
host artifacts/onix-root-tree/

This is temporary bootstrap architecture, but it is honest. Phase 201 remains a useful bridge/proof, even after Phase 202 adds host-side Moss, because it shows the exact point where the forge used to be required.

What the packages provide

The root tree receives package-owned files from:

onix-branding
onix-filesystem

Important package-owned files include:

/usr/lib/os-info.json
/usr/lib/os-release
/usr/share/onix/branding/logo.txt
/usr/share/onix/branding/logo.ansi
/usr/share/onix/filesystem-layout.md
/usr/share/defaults/etc/fstab
/usr/share/defaults/etc/issue
/usr/share/defaults/etc/motd
/usr/share/defaults/etc/profile.d/onix-path.sh

The important design rule stays the same:

packages own /usr
image assembly owns root-level machine glue

That is why onix-branding and onix-filesystem ship defaults under /usr/share/defaults/etc/ instead of directly owning live /etc.

What image assembly materializes

Phase 201 creates the first root-level machine view:

/etc/os-release -> ../usr/lib/os-release
/etc/issue
/etc/motd
/etc/fstab
/etc/profile.d/onix-path.sh
/etc/hostname

/boot
/dev
/efi
/home
/persist
/proc
/run
/sys
/tmp
/var

This is not random copying. It is the first image-assembly policy:

  • /etc/os-release is a compatibility symlink to the Moss-generated identity file under /usr/lib.
  • /etc/issue, /etc/motd, /etc/fstab, and /etc/profile.d/onix-path.sh are materialized from packaged defaults.
  • runtime/kernel directories such as /dev, /proc, /sys, and /run are created as empty mount points/placeholders. They are not package payload.
  • /tmp gets sticky permissions because users/processes share it.

What Phase 201 proves

Phase 201 proves:

  • the host-exported repo artifact is usable as an image input
  • the forge can install from that copied artifact by repo index
  • onix-branding and onix-filesystem compose into one root tree
  • image-owned /etc materialization is separated from package payload
  • the result can be exported back to the host as a clean artifact

It also verifies:

  • /usr/lib/os-release says NAME="ONIX" and ID="onix"
  • ANSI_COLOR matches the real ONIX blue
  • the ONIX terminal logo exists
  • /etc/os-release is the correct relative symlink
  • fstab contains onix-root and ONIX-PERSIST
  • no forbidden mixed-case brand spelling appears
  • Moss assembly state does not leak into the exported root tree

What Phase 201 does not prove

Phase 201 does not prove bootability.

The root tree still has no real ONIX kernel package, no init system package, no bootloader installation, no partition table, and no mounted filesystems. Those are later Phase 2 steps.

The point of this phase is to make the next step smaller. After 201, disk image work can consume a known-good root tree instead of solving packaging, repository, filesystem policy, and disk layout all at once.

Phase 202 — build host-side Moss

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 202
Underlying make target/scriptvm/phase2/build-host-moss.sh
Runs onhost
Main proof/artifactBuilds artifacts/host-tools/bin/moss from pinned os-tools.

Phase 202 is the next bootstrap cleanup.

Phase 201 proved that the exported repo can become a root tree, but it still used Moss inside the forge:

host repo artifact -> forge moss -> host root tree

That is useful, but it is not where we want to stay. The forge is temporary bootstrap scaffolding. Image assembly should become host-native:

host repo artifact -> host moss -> host root tree -> disk image

Phase 202 builds a host-side moss binary from the same pinned os-tools source used by Phase 0:

artifacts/host-tools/bin/moss

It requires Rust >= 1.91. The ONIX flake provides a new enough toolchain. If your shell still reports an older rustc, reload the dev shell:

direnv reload

or run the phase explicitly through Nix:

nix develop --impure -c make phase 202

It also records the source pin at:

artifacts/host-tools/os-tools.source
artifacts/host-tools/os-tools.git-deps

That file is generated and gitignored with the rest of artifacts/.

Source policy

ONIX currently treats AerynOS os-tools as pinned bootstrap tooling.

The current source of truth is still:

OS_TOOLS_REPO=https://github.com/AerynOS/os-tools.git
OS_TOOLS_REF=36f78e5bcfa9d594d65d1c6d2e332e950f3e4d0e

The pinned commit protects ONIX from upstream code changes.

It does not protect ONIX from source availability problems such as:

  • upstream repository deletion
  • upstream repository rename
  • GitHub outage
  • git dependency disappearing

So the future source-control policy should be:

1. mirror/fork os-tools into github.com/onix-os/os-tools
2. keep the exact same commit first
3. switch OS_TOOLS_REPO to the ONIX mirror
4. only diverge on an ONIX branch when ONIX needs patches

That means the first ONIX mirror step is boring on purpose. It is availability insurance, not a fork-war.

os-tools may also contain git dependencies such as boot tooling crates. When we switch to ONIX mirrors, we must audit the Cargo.toml/Cargo.lock graph and mirror every git dependency that matters for reproducible bootstrap.

At the current pin, Phase 202 records these git dependencies:

https://github.com/AerynOS/blsforme.git?rev=680720545303e123e47e0df07a8a85178c9f5c19
https://github.com/AerynOS/disks-rs?rev=d08bc11dcfb2ad4d031e2adccb97139f9d42c2b8
https://github.com/AerynOS/ent.git?rev=42416ecae36c0f29e07647747147672448241f85
https://github.com/AerynOS/os-info?rev=26b39c1d49c3b4f30d778729fb56958824c069de
https://github.com/kdl-org/kdl-rs?rev=e9df058c25cd4486df8fe568d2ff24ea2c4ed0e8

The ONIX mirror priority should be the AerynOS-owned dependencies first:

os-tools
blsforme
disks-rs
ent
os-info

kdl-rs is not AerynOS-owned, but it is still a pinned git dependency. We can leave it upstream for now or mirror it later if we want fully independent bootstrap availability.

What Phase 202 proves

Phase 202 proves:

  • the host dev shell has enough Rust/build tooling to compile Moss
  • the host can fetch and checkout the exact same pinned os-tools ref
  • the resulting host binary runs
  • ONIX has a generated host-tool location for future phases

It does not yet replace Phase 201.

That replacement should be a separate phase so the learning step is obvious:

203 = rebuild root tree using host Moss only

At that point the flow becomes:

artifacts/onix-publish/
   │
   ▼
artifacts/host-tools/bin/moss install --to artifacts/onix-root-tree

No SSH, no forge copy, no forge Moss.

Phase 203 — assemble the root tree with host-side Moss only

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 203
Underlying make target/scriptvm/phase2/build-root-tree-host.sh
Runs onhost
Main proof/artifactAssembles artifacts/onix-root-tree/ with host-side Moss only.

Phase 203 is the replacement for Phase 201.

It consumes the same Phase 1 exported repo artifact:

artifacts/onix-publish/

and the host-side Moss binary from Phase 202:

artifacts/host-tools/bin/moss

Then it builds the canonical root tree directly on the host:

artifacts/onix-root-tree/

The Phase 203 flow is:

host artifacts/onix-publish/
   │
   ▼
host artifacts/host-tools/bin/moss install --to root-tree
   │
   ▼
host materializes image-owned /etc glue
   │
   ▼
host artifacts/onix-root-tree/

There is no SSH. There is no forge copy. There is no forge Moss.

Why Phase 203 matters

This is the point where image assembly becomes host-native.

Before Phase 203, the host could hold artifacts, but the forge still understood the package format. After Phase 203, the host understands the package format too.

That changes the role of the forge:

before 203: forge is needed for root tree assembly
after 203:  forge is only bootstrap/build scaffolding

Future disk-image steps should consume the host-built root tree from Phase 203, not the bridge root tree from Phase 201.

Phase 201 vs Phase 203

Both phases produce:

artifacts/onix-root-tree/

But the assembly path is different:

201: host repo -> forge moss -> host root tree
203: host repo -> host moss  -> host root tree

Phase 203 intentionally overwrites the same canonical artifact path because the disk builder should not care how the tree was assembled. It only cares that the root tree contract is satisfied.

What Phase 203 verifies

Phase 203 verifies:

  • Phase 200 readiness still passes
  • Phase 1 exported repo artifact is clean
  • host Moss exists and matches the pinned OS_TOOLS_REF
  • SHA256SUMS validates
  • host Moss can add the local repo index
  • host Moss can install onix-branding and onix-filesystem
  • /usr/lib/system-model.kdl records the installed packages
  • /etc/os-release points to ../usr/lib/os-release
  • /etc/fstab contains onix-root and ONIX-PERSIST
  • /tmp has sticky 1777 permissions
  • no Moss assembly state leaks into the root tree
  • no forbidden mixed-case brand spelling appears

The generated system-model.kdl should now mention:

ONIX Phase 203 host image assembly repo

That tells us the root tree was produced by the host-native path, not the earlier forge path.

Phase 204 — define image/disk assembly contract

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 204
Underlying make target/scriptvm/phase2/verify-image-contract.sh
Runs onhost
Main proof/artifactVerifies the disk/image contract before loop devices, filesystems, or mounts.

Phase 204 is deliberately boring and important.

It does not create a disk image. It does not partition anything. It does not format filesystems. It does not mount anything. It does not use sudo.

It only verifies that this book page contains the contract for the next layer.

The reason is simple: before we ask Linux for loop devices, partitions, mounts, and filesystems, we want the target shape written down in human language. Disk-building mistakes are easy to make and annoying to debug. A contract phase lets us agree on the shape first.

The artifact names

The canonical root tree input is still:

artifacts/onix-root-tree/

The future raw disk image will be:

artifacts/onix-image/onix.raw

Temporary mount/work state for image assembly should live under:

artifacts/onix-image-work/

All of these paths are under artifacts/, so they are generated local build outputs and are gitignored.

Root tree vs disk image, again

The root tree is a directory on the host:

artifacts/onix-root-tree/

The disk image is a fake disk file:

artifacts/onix-image/onix.raw

The future Phase 205 job is to copy the root tree into a filesystem inside the disk image:

artifacts/onix-root-tree/
   │
   │ copied into the root partition
   ▼
artifacts/onix-image/onix.raw

That raw image can later be attached to QEMU as if it were a real disk.

Planned GPT partition table

The first ONIX image should use GPT.

GPT is the modern partition-table format used by UEFI systems. It lets one disk contain several named partitions. Names matter here because ONIX will mount partitions by label instead of by fragile device names.

The contract is:

#LabelFilesystemMount pointEarly purpose
1ONIX-ESPvfat/efiEFI System Partition for firmware-visible boot files later
2ONIX-BOOTvfat/bootkernel/initramfs/BLS/systemd-boot files later
3onix-rootxfs/generated ONIX root filesystem from artifacts/onix-root-tree/
4ONIX-PERSISTxfs/persistpersistent machine data such as homes and Nix store later

Proposed early sizes:

ONIX-ESP      512 MiB
ONIX-BOOT       1 GiB
onix-root       8 GiB minimum for the first image
ONIX-PERSIST    rest of the disk

Those sizes can change later, but the labels and mount roles are the important contract.

Why vfat for /efi and /boot

UEFI firmware understands the EFI System Partition as a FAT filesystem. In Linux tools that usually means vfat.

So /efi must be vfat.

For the first image, /boot is also planned as vfat because it keeps the boot partition simple and readable by the early boot tooling. Later we can revisit that if the real boot model needs a different split.

The first fstab marks /efi and /boot as nofail.

That is deliberate. The machine can reach userspace without those partitions mounted. During early bootstrap we may not yet have the final kernel-module and modprobe policy needed for vfat mounts after switch-root. A failed /efi or /boot mount should be visible in logs, but it should not force the whole boot into emergency mode.

Why xfs for / and /persist

The root and persist partitions are Linux-owned filesystems. They need normal Unix permissions, symlinks, device-node support, and good behavior for large trees.

So the contract uses xfs for:

/
/persist

The Phase 1 onix-filesystem package already emits the same policy in the default fstab template:

LABEL=ONIX-ESP      /efi      vfat  rw,relatime,noatime,nofail,x-systemd.device-timeout=10s
LABEL=ONIX-BOOT     /boot     vfat  rw,relatime,noatime,nofail,x-systemd.device-timeout=10s
LABEL=onix-root     /         xfs
LABEL=ONIX-PERSIST  /persist  xfs

That is the reason Phase 200 checks for mkfs.xfs.

Why labels instead of /dev/vda3

Inside Linux, disks appear with names like:

/dev/vda
/dev/sda
/dev/nvme0n1

Partitions appear as:

/dev/vda1
/dev/vda2
/dev/vda3

Those names depend on the virtual hardware, boot order, and driver timing. They are not the identity of the filesystem.

Filesystem labels are much more stable:

LABEL=onix-root
LABEL=ONIX-PERSIST

That is why the ONIX fstab contract mounts by label.

What gets copied into /

The root partition labeled onix-root receives the contents of:

artifacts/onix-root-tree/

That means this host file:

artifacts/onix-root-tree/usr/lib/os-release

becomes this file inside the future machine:

/usr/lib/os-release

And this host symlink:

artifacts/onix-root-tree/etc/os-release -> ../usr/lib/os-release

becomes:

/etc/os-release -> ../usr/lib/os-release

What does not get copied as real data

Some directories are mount points or runtime filesystems:

/dev
/proc
/sys
/run

They should exist as directories in the image, but their contents are created or mounted at boot. We do not copy host /dev into the image. We do not copy host /proc. Those are views of the running host kernel, not package payload.

/persist is also a mount point. The image will have an ONIX-PERSIST partition mounted there. Later ONIX can bind persistent paths from it:

/persist/home -> /home
/persist/nix  -> /nix

What Phase 204 checks

make phase 204 verifies:

  • this Phase 204 section exists
  • the future disk path is artifacts/onix-image/onix.raw
  • the source root tree path is artifacts/onix-root-tree/
  • all required labels are documented:
    • ONIX-ESP
    • ONIX-BOOT
    • onix-root
    • ONIX-PERSIST
  • all required mount points are documented:
    • /efi
    • /boot
    • /
    • /persist
  • the contract mentions both vfat and xfs
  • the current root tree still contains /usr/lib/os-release
  • the current root tree fstab still references the planned labels

This makes Phase 204 a safe checkpoint between “we can assemble files” and “we are about to create filesystems”.

What Phase 204 does not prove

Phase 204 does not prove booting.

At this point ONIX still does not have:

kernel package
initramfs policy
systemd package
systemd-boot installation
BLS entries
real first userspace path

So the next disk phase should be a non-booting skeleton first. That lets us verify partition creation and root-tree copy before mixing in boot complexity.

The immediate sequence after this contract is:

Phase 205 -> create the non-booting disk/root skeleton
Phase 206 -> install the systemd-boot/BLS skeleton

Phase 205 — create first non-booting disk/root skeleton

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 205
Underlying make target/scriptvm/phase2/build-image-skeleton.sh
Runs onhost with rootful loop/mount/filesystem work
Main proof/artifactCreates artifacts/onix-image/onix.raw as a non-booting disk/root skeleton.

Phase 205 is the first phase that creates the real future ONIX disk shape.

It takes this directory:

artifacts/onix-root-tree/

and creates this raw disk image:

artifacts/onix-image/onix.raw

This is a raw disk image, meaning it is just bytes arranged like a normal disk. QEMU can later attach it as a virtual disk.

Phase 205 is rootful because Linux only lets root do some disk operations:

losetup     attach file as loop disk
sgdisk      write partition table
mkfs.*      create filesystems
mount       mount filesystems
umount      unmount filesystems

The script follows the same pattern as the forge disk builder: it starts as your user, then re-execs itself through sudo only when root is needed. Run make doctor or make phase 001 once if the passwordless builder rule needs to be refreshed.

What Phase 205 creates

The generated image path is:

artifacts/onix-image/onix.raw

Default size:

12 GiB

The default partition plan is:

#LabelFilesystemSizeMount during assembly
1ONIX-ESPvfat512 MiB/efi
2ONIX-BOOTvfat1 GiB/boot
3onix-rootxfs8 GiB/
4ONIX-PERSISTxfsrest/persist

The sizes can be overridden later with environment variables:

ONIX_IMAGE_SIZE
ONIX_IMAGE_ESP_SIZE
ONIX_IMAGE_BOOT_SIZE
ONIX_IMAGE_ROOT_SIZE

What a loop device is

The image is a normal file on the host:

artifacts/onix-image/onix.raw

But partitioning tools expect a block device, not a regular file.

Linux loop devices solve that. losetup temporarily presents the file as a fake disk:

artifacts/onix-image/onix.raw
   │
   │ losetup
   ▼
/dev/loopX

Then partitions appear as:

/dev/loopXp1
/dev/loopXp2
/dev/loopXp3
/dev/loopXp4

When the phase finishes, it unmounts the filesystems and detaches the loop device. The final artifact is only the .raw file.

What gets copied

Phase 205 mounts the onix-root partition and copies the root tree into it:

artifacts/onix-root-tree/  ->  onix-root filesystem mounted at /

It uses tar with “do not preserve host owner” behavior so files inside the image become root:root, not bresilla:bresilla.

That matters because this host-owned file:

artifacts/onix-root-tree/usr/lib/os-release

must become this root-owned file inside the image:

/usr/lib/os-release

What Phase 205 adds after the copy

The root tree has the main OS payload and mount points.

The disk assembly phase also creates persistent bind-source directories on the ONIX-PERSIST partition:

/persist/home
/persist/nix

and ensures the root filesystem has the bind target:

/nix

That matches the default fstab lines:

/persist/home       /home     none  bind
/persist/nix        /nix      none  bind

What Phase 205 verifies

make phase 205 verifies:

  • Phase 204 contract still passes
  • GPT partition names are correct
  • filesystem labels are correct
  • filesystem types are correct
  • /usr/lib/os-release exists in the root filesystem
  • copied files are root-owned inside the image
  • /tmp is still mode 1777
  • /etc/fstab still refers to the planned labels
  • /persist/home, /persist/nix, and /nix exist
  • no EFI loader exists yet

The last check is intentional. If Phase 205 finds:

/efi/EFI/BOOT/BOOTX64.EFI

it fails, because that would mean we accidentally started bootloader work too early.

Why Phase 205 is still not bootable

A disk can have a correct root filesystem and still not boot.

To boot, it also needs things like:

kernel
initramfs
init system
bootloader
bootloader entries
kernel command line

Phase 205 avoids all of that on purpose. It proves only:

root tree -> real partitioned disk image

That keeps the debugging surface small. If Phase 205 passes, then a future boot failure is probably in the boot layer, not in the root-tree-copy layer.

Phase 206 — install the systemd-boot/BLS skeleton

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 206
Underlying make target/scriptvm/phase2/build-image-skeleton.sh --boot-skeleton
Runs onhost with rootful image mount work
Main proof/artifactInstalls the systemd-boot/BLS skeleton into the image.

Phase 206 starts the boot layer, but still does not pretend the OS can fully boot yet.

The basic boot chain we are building toward is:

UEFI firmware
  -> EFI loader on ONIX-ESP
  -> systemd-boot
  -> BLS entry on ONIX-BOOT
  -> kernel
  -> initramfs
  -> mount onix-root as /
  -> run /usr/lib/systemd/systemd

Phase 206 installs only the first bootloader/config part:

UEFI firmware
  -> systemd-boot
  -> BLS entry

It does not install:

kernel
initramfs
systemd userspace

So the image is still not a complete bootable ONIX system. That is intentional.

If Phase 200 or 206 says bootctl or systemd-bootx64.efi is missing, reload the dev shell:

direnv reload

flake.nix exports the host-side ONIX_SYSTEMD_BOOT_EFI path used by this phase.

Why systemd-boot, not GRUB

For the real ONIX image, we want the simple UEFI path:

UEFI + systemd-boot + Boot Loader Specification entries

GRUB was useful in Phase 0 because Alpine needed a practical throwaway forge boot path. ONIX itself should not inherit that forge choice.

systemd-boot is smaller and more direct:

EFI binary on /efi
plain text loader config
plain text boot entries

That makes it easier to understand and easier to generate.

What the ESP is for

ONIX-ESP is mounted at:

/efi

UEFI firmware reads this partition before Linux is running. That means it must contain the EFI executable that firmware can launch.

Phase 206 writes:

/efi/EFI/systemd/systemd-bootx64.efi
/efi/EFI/BOOT/BOOTX64.EFI
/efi/loader/loader.conf

BOOTX64.EFI is the standard removable-media path. OVMF/QEMU can find it without us writing host EFI variables.

What ONIX-BOOT is for

ONIX-BOOT is mounted at:

/boot

It is the future boot asset partition. Phase 206 writes the future BLS entry:

/boot/loader/entries/onix-phase-206.conf

That entry points to future kernel paths:

/boot/ONIX/vmlinuz
/boot/ONIX/initramfs.img

The entry also says the future kernel should mount:

root=LABEL=onix-root

and then start:

init=/usr/lib/systemd/systemd

Those files do not exist yet. That is why Phase 206 is a boot skeleton, not a boot success phase.

What BLS means

BLS means Boot Loader Specification.

For us, the important idea is simple: boot entries are normal text files.

Instead of hiding boot configuration inside a generated GRUB config, ONIX can write a file like:

title ONIX
linux /ONIX/vmlinuz
initrd /ONIX/initramfs.img
options root=LABEL=onix-root rw init=/usr/lib/systemd/systemd

That is easy to inspect, easy to version conceptually, and easy for an image builder to generate.

What Phase 206 verifies

make phase 206 verifies:

  • Phase 204 contract still passes
  • the Phase 205 partition labels still exist
  • systemd-bootx64.efi is available from the dev shell
  • /efi/EFI/systemd/systemd-bootx64.efi is installed
  • /efi/EFI/BOOT/BOOTX64.EFI is installed
  • /efi/loader/loader.conf selects onix-phase-206.conf
  • /boot/loader/entries/onix-phase-206.conf exists
  • the entry points at root=LABEL=onix-root
  • the entry points at /usr/lib/systemd/systemd
  • kernel/initramfs/systemd are still absent

The last item is important. Phase 206 fails if it accidentally becomes a fake “it boots” phase. We want each layer to prove exactly one thing.

Phase 207 — kernel + initramfs contract

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 207
Underlying make target/scriptvm/phase2/verify-kernel-initramfs-plan.sh
Runs onhost
Main proof/artifactVerifies the kernel/initramfs ownership and boot-path contract.

Phase 207 is another contract phase.

It does not copy a kernel into the image. It does not build an initramfs. It does not mount the image. It does not boot QEMU.

Phase 207 does not copy kernel files because the kernel is too important to smuggle in accidentally. If we copied a random host kernel now, the image might move forward, but ONIX would not have learned how it owns its own boot payload.

What the kernel is

The Linux kernel is the first real Linux program that runs after the bootloader.

Very simplified:

firmware
  -> bootloader
  -> kernel
  -> first userspace program

The kernel is responsible for things like:

CPU scheduling
memory management
device drivers
filesystems
processes
mounting the root filesystem

For ONIX, the future kernel path selected by Phase 206 is:

/boot/ONIX/vmlinuz

vmlinuz is the normal name for a compressed Linux kernel image.

What the initramfs is

initramfs means “initial RAM filesystem”.

It is a tiny temporary filesystem loaded into memory before the real root filesystem is mounted.

Boot flow with initramfs:

firmware
  -> systemd-boot
  -> kernel
  -> initramfs in RAM
  -> find real root filesystem
  -> mount real root filesystem at /
  -> run /usr/lib/systemd/systemd

The initramfs exists because the kernel often needs help before it can mount the real root filesystem. For example, it may need userspace tools or modules to find:

LABEL=onix-root

and mount it as:

/

For ONIX, the future initramfs path selected by Phase 206 is:

/boot/ONIX/initramfs.img

Why root=LABEL=onix-root needs initramfs help

The Phase 206 BLS entry contains:

root=LABEL=onix-root rootfstype=xfs rw init=/usr/lib/systemd/systemd

That means:

find the filesystem labeled onix-root
mount it as /
use xfs as the root filesystem type
start /usr/lib/systemd/systemd

Device names like /dev/vda3 can change. Labels are stable, so the contract keeps:

root=LABEL=onix-root

But resolving a label usually needs early userspace support. That is exactly the initramfs job.

Minimum early-boot capabilities

For the first QEMU ONIX image, the initramfs must be able to handle:

virtio_pci   QEMU virtio PCI transport
virtio_blk   QEMU virtio block disk
xfs          root filesystem type
vfat         ESP/boot filesystem type, useful for inspection and later tooling
devtmpfs     early /dev population
proc         /proc mount
sysfs        /sys mount

The must-have path is:

virtio disk -> find LABEL=onix-root -> mount xfs root -> exec systemd

If any of those pieces are missing, the bootloader can load the kernel but the kernel may panic because it cannot find or mount /.

ONIX ownership decision

The Phase 207 decision is:

do not use the host kernel as the final ONIX kernel

The host kernel belongs to the developer machine. The Alpine forge kernel belongs to the throwaway forge. A Nixpkgs kernel belongs to the toolbox/source environment.

ONIX needs its own explicit boot payload contract.

The planned package split is:

onix-kernel      owns the kernel image and kernel modules
onix-initramfs   owns or generates the initramfs image

That split may evolve, but the ownership boundary matters:

package content        -> /usr/lib/kernel and /usr/lib/modules
image boot material    -> /boot/ONIX/vmlinuz and /boot/ONIX/initramfs.img
bootloader config      -> /boot/loader/entries/*.conf

In other words, packages should provide reproducible boot inputs, and image assembly should place the selected boot artifacts where systemd-boot expects them.

Future file contract

The current Phase 206 BLS entry expects:

/boot/ONIX/vmlinuz
/boot/ONIX/initramfs.img

The future root filesystem must contain:

/usr/lib/systemd/systemd

The future package payload should make kernel/module content available from stable package-owned paths, such as:

/usr/lib/kernel/vmlinuz
/usr/lib/modules/<kernel-version>/

Then image assembly can copy or link the selected boot artifacts into:

/boot/ONIX/

What Phase 207 verifies

make phase 207 verifies:

  • this Phase 207 section exists
  • the contracted kernel path is /boot/ONIX/vmlinuz
  • the contracted initramfs path is /boot/ONIX/initramfs.img
  • the contracted init path is /usr/lib/systemd/systemd
  • the boot arguments still use root=LABEL=onix-root
  • the boot arguments still use rootfstype=xfs
  • the plan mentions the minimum early-boot pieces:
    • virtio_pci
    • virtio_blk
    • xfs
    • vfat
  • the plan names onix-kernel
  • the plan names onix-initramfs
  • the Phase 206 image script still writes the same BLS paths

This makes Phase 207 a checkpoint between “we have a bootloader entry” and “we are ready to build or import a real kernel/initramfs payload”.

What Phase 207 does not prove

Phase 207 does not prove:

kernel compiles
initramfs boots
systemd runs
QEMU reaches userspace

Those are later phases. Phase 207 only prevents us from taking a shortcut that would hide ownership problems.

Phase 208 — systemd userspace contract

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 208
Underlying make target/scriptvm/phase2/verify-systemd-userspace-plan.sh
Runs onhost
Main proof/artifactVerifies the systemd userspace ownership and PID 1 contract.

Phase 208 is also a contract phase.

Phase 208 does not build systemd. It does not copy host systemd. It does not copy Nix systemd. It does not mount the image. It does not boot QEMU.

This phase exists because the Phase 206 boot entry already says:

init=/usr/lib/systemd/systemd
systemd.unit=multi-user.target

That means the future kernel/initramfs handoff expects the real root filesystem to contain:

/usr/lib/systemd/systemd

Before we build or import that file, we need to say what owns it and what minimum userspace shape must exist around it.

What PID 1 means

When Linux starts userspace, the first normal process gets process ID 1:

PID 1

PID 1 is special. It becomes the init system for the machine.

It is responsible for starting and supervising the rest of userspace:

mounts
device management
services
login
shutdown
reboot
cleanup of orphaned processes

For ONIX, the planned PID 1 path is:

/usr/lib/systemd/systemd

That is why Phase 206 put this on the kernel command line:

init=/usr/lib/systemd/systemd

Why not copy host systemd

The Phase 208 decision is:

do not copy host systemd
do not copy Nix systemd

The host systemd belongs to the developer machine. The Nix systemd belongs to the Nix toolbox environment.

ONIX is meant to be a musl-based OS. A random host or Nix systemd may be built for a different libc, with a different layout, with different assumptions about paths, users, groups, services, and dependencies.

So the future package must be ONIX-owned:

onix-systemd

That package name is the contract for now. It may eventually be split into smaller packages, but the ownership rule is clear: ONIX must provide its own systemd userspace rather than smuggling in the host one.

What systemd userspace must include

The minimum future onix-systemd package needs more than one binary.

At minimum, the contract needs:

/usr/lib/systemd/systemd
/usr/lib/systemd/systemd-udevd
/usr/bin/systemctl
/usr/bin/journalctl
/usr/lib/systemd/system/multi-user.target

systemd-udevd matters because device nodes and device events are part of turning early boot into a usable machine.

multi-user.target matters because the Phase 206 boot entry already asks for:

systemd.unit=multi-user.target

So the target file must exist at:

/usr/lib/systemd/system/multi-user.target

Runtime filesystems systemd expects

Some paths are not normal package payload. They are runtime filesystems mounted by the kernel, initramfs, or early userspace:

/run
/dev
/proc
/sys

ONIX packages can create the mount-point directories, but they should not ship host contents for those paths.

That matches the earlier root-tree rule:

/dev   runtime devices
/proc  kernel process/info view
/sys   kernel device/info view
/run   runtime state

Machine identity and defaults

systemd also expects some machine-local state and policy.

Important early files include:

/etc/machine-id
/etc/fstab

/etc/machine-id is the unique machine identity. It should not be a baked-in shared ID copied into every image forever. The first real boot path needs a policy for creating or seeding it safely.

/etc/fstab already comes from the ONIX filesystem package defaults and is materialized by image assembly.

tmpfiles and sysusers

Two common systemd mechanisms matter for package integration:

tmpfiles
sysusers

tmpfiles describes runtime directories, files, permissions, and cleanup rules.

sysusers describes system users and groups that packages need.

ONIX should eventually support package-owned defaults such as:

/usr/lib/tmpfiles.d/*.conf
/usr/lib/sysusers.d/*.conf

This lets packages declare system integration without editing live /etc directly.

Future file contract

The future root filesystem must provide:

/usr/lib/systemd/systemd
/usr/lib/systemd/systemd-udevd
/usr/lib/systemd/system/multi-user.target

The future image or first-boot policy must handle:

/etc/machine-id
/run
/dev
/proc
/sys

The future package name for this responsibility is:

onix-systemd

Again: ONIX should build or package this intentionally for its musl base.

What Phase 208 verifies

make phase 208 verifies:

  • this Phase 208 section exists
  • the planned PID 1 path is /usr/lib/systemd/systemd
  • the boot entry still asks for systemd.unit=multi-user.target
  • the target path is /usr/lib/systemd/system/multi-user.target
  • the plan names onix-systemd
  • the plan says musl
  • the plan says do not copy host systemd
  • the plan says do not copy Nix systemd
  • the plan mentions systemd-udevd
  • the plan mentions /etc/machine-id
  • the plan mentions /run, /dev, /proc, and /sys
  • the plan mentions tmpfiles
  • the plan mentions sysusers
  • the Phase 206 image script still points at /usr/lib/systemd/systemd

This makes Phase 208 a checkpoint between “the boot entry names systemd” and “ONIX actually provides systemd userspace”.

What Phase 208 does not prove

Phase 208 does not prove:

systemd builds on musl
systemd starts as PID 1
udev works
services start
the image boots

Those are later phases. This phase only protects the ownership boundary.

Phase 209 — systemd-on-musl feasibility gate

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 209
Underlying make target/scriptvm/phase2/check-systemd-musl.sh
Runs onhost
Main proof/artifactConfirms pinned nixpkgs can represent a musl-targeted systemd build graph.

Phase 209 checks the scary question directly:

can systemd exist in a musl-only ONIX world?

Short answer:

glibc is not a hard requirement
musl is still a risk

So we continue with systemd-on-musl. But we also do not declare victory yet.

Phase 209 does not build systemd. It does not install systemd. It does not mount the image. It does not boot QEMU.

It only checks whether the upstream and pinned-tooling story is plausible enough to keep going.

What upstream says

The current upstream systemd README lists both libc families in its requirements:

glibc >= 2.34
musl >= 1.2.6

It also says musl is used by building systemd with:

-Dlibc=musl

That means systemd-on-musl is an upstream-recognized build mode, not something we invented.

Source:

https://raw.githubusercontent.com/systemd/systemd/main/README

What our pinned nixpkgs says

Our pinned nixpkgs exposes:

pkgsMusl.systemd

The local metadata check currently reports:

name      : systemd-259.3
host libc : musl
broken    : false
flag      : -Dlibc=musl
musl      : musl 1.2.5

That means Nix can describe a musl-targeted systemd derivation for our pinned tooling.

Important nuance: current upstream main says musl >= 1.2.6, while our pinned Nix metadata reports musl 1.2.5 for systemd-259.3. That does not automatically kill the plan because the pinned package is an older systemd version, but it does mean we must treat this as a feasibility gate, not final proof.

What the pinned source says

The pinned systemd source has a Meson option:

option('libc', type : 'combo', choices : ['glibc', 'musl'])

Its Meson logic also has musl-specific handling, and it disables at least one feature that musl does not support:

utmp

That matters because it tells us musl support is not just a string in Nix. The source tree itself contains a musl path.

What the dry-run proves

make phase 209 also asks Nix to plan:

pkgsMusl.systemd

with:

nix build --dry-run

Dry-run does not compile anything. It only proves Nix can construct the build graph.

If dry-run fails, we should not continue with systemd until we understand why.

What Phase 209 proves

make phase 209 proves:

  • this Phase 209 section exists
  • upstream has a musl build mode
  • the pinned Nix package path exists as pkgsMusl.systemd
  • the pinned package is named systemd-259.3
  • the pinned package targets musl
  • the pinned package is not marked broken
  • the pinned package uses -Dlibc=musl
  • Nix can plan the build graph

This is enough to say:

continue systemd-on-musl

What Phase 209 does not prove

Phase 209 does not prove:

systemd compiles successfully in our own boulder recipe
systemd links exactly how ONIX wants
systemd starts as PID 1
udev works
networking works
services work
boot reaches login

Those are still hard problems.

The current decision is:

continue systemd-on-musl

Phase 210 — init path decision contract

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 210
Underlying make target/scriptvm/phase2/verify-init-decision.sh
Runs onhost
Main proof/artifactRecords the current init decision: systemd-on-musl plus systemd-boot.

Phase 210 turns the Phase 209 probe into an explicit project decision.

Phase 210 does not build the init system. It does not install systemd. It does not mount the image. It does not boot QEMU.

It only records how ONIX will proceed.

The decision

The Phase 210 decision is:

init path: systemd-on-musl
bootloader: systemd-boot

Project rule:

keep systemd if we can

In plain words: ONIX uses systemd as PID 1.

That means we continue with the systemd path for now because Phase 209 showed:

pkgsMusl.systemd exists
pkgsMusl.systemd targets musl
pkgsMusl.systemd is not marked broken
pkgsMusl.systemd uses -Dlibc=musl
Nix can plan the build graph

But we do not pretend that systemd is proven boot-ready. The systemd path still has to prove itself in real ONIX builds and boots.

Why bootloader and init are separate

The bootloader chooses and launches the kernel.

The init system is the first userspace process after the kernel mounts the real root filesystem.

Those are different jobs:

systemd-boot  -> bootloader
systemd       -> init system / PID 1

That is why the decision names both parts explicitly:

systemd-boot loads the kernel
systemd runs as PID 1

What this means for the boot entry

The BLS entry keeps this kernel command line intent:

init=/usr/lib/systemd/systemd systemd.unit=multi-user.target

That path is the userspace handoff point:

kernel -> root filesystem -> /usr/lib/systemd/systemd

So Phase 211 and later must place a real systemd userspace at that path.

What Phase 210 verifies

make phase 210 verifies:

  • this Phase 210 section exists
  • the init path is systemd-on-musl
  • the bootloader is systemd-boot
  • the project rule says keep systemd if we can
  • the plan says ONIX uses systemd as PID 1
  • the boot entry points at /usr/lib/systemd/systemd
  • the plan mentions systemd starts as PID 1
  • the plan mentions udev/device setup works
  • the plan mentions basic services work
  • the Phase 206 boot skeleton still follows the systemd path

What Phase 210 does not prove

Phase 210 does not prove:

systemd builds
systemd boots
services work
QEMU reaches login

It only makes the next engineering decision explicit.

Phase 211 — first kernel + initramfs payload

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 211
Underlying make target/scriptvm/phase2/build-image-skeleton.sh --kernel-payload
Runs onhost with rootful image mount work
Main proof/artifactInstalls the first kernel/initramfs payload into /boot/ONIX/ inside the image.

Phase 211 installs the first real files at the paths that Phase 206 and Phase 207 already promised:

/boot/ONIX/vmlinuz
/boot/ONIX/initramfs.img

This is the first time the ONIX image contains a kernel and initramfs payload.

What a payload is

A payload is the thing a previous layer hands to the next layer.

For this part of boot:

systemd-boot payload -> Linux kernel + initramfs
Linux kernel payload -> mounted root filesystem
root filesystem payload -> /usr/lib/systemd/systemd
systemd payload -> services

So Phase 211 is not “the whole OS boots now”.

Phase 211 only gives systemd-boot something real to load.

Where the first payload comes from

The default Phase 211 source is:

vm/state/vmlinuz-virt
vm/state/initramfs-virt

Those files are exported by the forge disk build in Phase 0.

That makes them a temporary bootstrap source, not the final ONIX kernel package story.

The final shape is still:

onix-kernel
onix-initramfs

But using the exported forge payload is useful because it is already known to be a QEMU-capable kernel/initramfs pair.

Why Phase 211 checks the initramfs

ONIX root is XFS:

LABEL=onix-root  /  xfs

That means the initramfs must understand XFS before the kernel can mount /.

If the initramfs cannot mount /, the boot fails before systemd even has a chance to start.

So make phase 211 checks the initramfs contents before copying it.

It requires:

/init
xfs.ko
vfat.ko
virtio_blk.ko

Those mean:

itemwhy it matters
/initthe first program inside the initramfs
xfs.kolets early boot mount the ONIX XFS root
vfat.kolets early boot understand FAT boot files if needed
virtio_blk.kolets early boot see the QEMU virtio disk

If the current exported forge initramfs is old, Phase 211 may stop with:

initramfs lacks xfs.ko

That is good. It means the verifier prevented us from installing a boot payload that cannot mount the ONIX root filesystem.

The forge setup now requests XFS support when it creates the exported initramfs, so rebuilding the forge disk produces a better payload.

What Phase 211 writes

make phase 211 mounts the existing ONIX image and writes:

/boot/ONIX/vmlinuz
/boot/ONIX/initramfs.img
/boot/ONIX/README.phase211
/boot/loader/entries/onix-phase-211.conf
/efi/loader/loader.conf

The BLS entry becomes:

title ONIX
sort-key onix
version phase-211
linux /ONIX/vmlinuz
initrd /ONIX/initramfs.img
options root=LABEL=onix-root rootfstype=xfs rw init=/usr/lib/systemd/systemd systemd.unit=multi-user.target console=tty0 console=ttyS0,115200

The important new part is not the path. The path already existed in the contract.

The important new part is that the files now exist and match the selected source payload byte-for-byte.

What Phase 211 verifies

make phase 211 verifies:

  • the Phase 207 kernel/initramfs contract still exists
  • the source kernel file exists and is non-empty
  • the source initramfs exists and is non-empty
  • the initramfs can be listed
  • the initramfs contains /init
  • the initramfs contains xfs.ko
  • the initramfs contains vfat.ko
  • the initramfs contains virtio_blk.ko
  • the Phase 206 boot skeleton exists first
  • /boot/ONIX/vmlinuz is installed
  • /boot/ONIX/initramfs.img is installed
  • the installed files match the source files
  • the default boot entry is onix-phase-211.conf
  • the boot entry still points to /usr/lib/systemd/systemd
  • the image still does not contain systemd userspace yet

What Phase 211 does not prove

Phase 211 does not prove:

the kernel boots
the initramfs mounts the root filesystem
systemd exists
systemd starts
QEMU reaches login

That is why Phase 212 is still needed.

Phase 212 — first QEMU boot probe

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 212
Underlying make target/scriptvm/phase2/boot-probe.sh
Runs onhost QEMU process
Main proof/artifactBoots the ONIX image in QEMU and captures serial evidence for the current image layer.

Phase 212 is the QEMU boot probe for the generated ONIX image.

It is still a probe.

It is not a promise that the OS reaches login.

The exact meaning of the probe depends on which image layers have already been staged.

After Phase 211, the image has:

disk partitions
root filesystem skeleton
systemd-boot
BLS boot entry
kernel
initramfs

At that point, Phase 212 is expected to find the next missing userspace layer.

After Phase 213, the image also has:

/usr/lib/systemd/systemd

After Phase 214, the image also has:

/usr/bin/kmod
/usr/sbin/modprobe
/usr/lib/modules/<kernel-release>

So Phase 212 is reusable. It asks:

what exact boot milestone does the current image reach?

The expected learning value is still not “a finished OS”.

The expected learning value is evidence:

does OVMF find systemd-boot?
does systemd-boot load /boot/ONIX/vmlinuz?
does the kernel receive the right command line?
does the initramfs get far enough to try the real root?
does the kernel hand off to systemd?
does systemd reach multi-user.target?
do /boot and /efi mount cleanly?
what is the next missing layer or warning?

Why a boot probe is useful even before login

Boot is a chain.

Each link hands control to the next link:

QEMU
  -> OVMF firmware
  -> systemd-boot
  -> Linux kernel
  -> initramfs
  -> ONIX root filesystem
  -> /usr/lib/systemd/systemd

If the chain breaks, the serial log tells us which link broke.

That is better than guessing.

Why Phase 212 uses a serial log

Graphical boot output is easy to miss and hard to copy.

Serial output is plain text.

Phase 212 writes it here:

vm/state/phase212.serial.log

The probe also prints the serial log while QEMU is running so you can watch the boot happen.

How to watch Phase 212 attached

Normal Phase 212 is headless and automatic:

make phase 212

If you want to see the serial console directly in your terminal, run one of these:

ATTACHED=1 make phase 212
make phase 212 ATTACHED=1

Those are equivalent.

Attached mode runs QEMU in the foreground. It does not stop QEMU automatically. The default attached display is the terminal serial console:

ONIX_BOOT_PROBE_DISPLAY=serial

To exit terminal serial mode, press:

Ctrl-a then x

or press Ctrl-C.

If you explicitly want a GTK window:

ONIX_BOOT_PROBE_DISPLAY=gtk ATTACHED=1 make phase 212

If you explicitly want VNC:

ONIX_BOOT_PROBE_DISPLAY=vnc ATTACHED=1 make phase 212

Then connect a VNC viewer to:

127.0.0.1:5900

There is also a QEMU -nographic mode:

ONIX_BOOT_PROBE_DISPLAY=none ATTACHED=1 make phase 212

Why normal Phase 212 stops QEMU itself

Normal Phase 212 is not meant to leave you trapped in a VM.

It runs QEMU in the background, waits for a short probe window, captures the serial log, then stops only the Phase 212 QEMU process:

process name: onix-phase212

It does not kill unrelated QEMU VMs.

Why Phase 212 uses a snapshot disk

The QEMU disk is opened with snapshot writes.

That means the boot probe can read the generated image, but runtime writes do not permanently change artifacts/onix-image/onix.raw.

This keeps the image artifact reproducible while we are still learning.

What Phase 212 verifies

make phase 212 verifies:

  • artifacts/onix-image/onix.raw exists
  • OVMF firmware exists
  • QEMU can launch the image
  • serial output is captured
  • the Linux kernel starts
  • the kernel command line contains root=LABEL=onix-root
  • the kernel command line contains init=/usr/lib/systemd/systemd
  • the log contains useful boot evidence
  • if present, systemd userspace can be observed in the serial log
  • if reached, multi-user.target is reported as the strongest milestone

What Phase 212 does not prove

Phase 212 does not prove:

networking works
login works
the final package ownership model is complete
all mounts are strict and clean

Those need later phases.

What comes after 212?

Once Phase 212 proves the image reaches systemd, the next safe progression is to remove bootstrap shortcuts one by one.

Examples:

214 = add the first module/kmod payload
future = make /boot and /efi strict once they mount cleanly
future = add real login/user/networking policy
future = replace imported bootstrap payloads with ONIX-owned stones

The key learning point: Phase 2 is where we stop proving packages only in disposable targets and start assembling the actual ONIX machine layout one layer at a time.

Phase 213 — first musl systemd userspace payload

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 213
Underlying make target/scriptvm/phase2/build-image-skeleton.sh --systemd-payload
Runs onhost, then rootful image mount/copy work
Main proof/artifactStages a first musl-targeted systemd userspace payload so /usr/lib/systemd/systemd exists in the ONIX image.

Phase 213 answers the exact failure from Phase 212.

Phase 212 proved the boot chain reached the real root filesystem. Then the initramfs tried to hand control to:

/usr/lib/systemd/systemd

and failed because that path did not exist yet.

So Phase 213 is not about the bootloader. It is not about the kernel. It is not about partitions. Those layers already reached their next checkpoint.

Phase 213 is about the next missing layer:

systemd userspace

What gets staged

The immediate required path is:

/usr/lib/systemd/systemd

But systemd is not one standalone static binary. It has helper binaries, unit files, libraries, and runtime expectations.

So the first payload also needs at least:

/usr/lib/systemd/systemd-udevd
/usr/lib/systemd/system/multi-user.target
/usr/bin/systemctl
/usr/bin/journalctl

That is why this phase stages a runtime closure, not just one file.

One layout detail matters here: the pinned Nix pkgsMusl.systemd output keeps the stock unit files under:

/nix/store/...-systemd-259.3/example/systemd/system

ONIX exposes that directory at the conventional runtime path:

/usr/lib/systemd/system

so systemd.unit=multi-user.target can resolve the target through the normal systemd unit search path.

For this particular Nix-built systemd, there is another important path. The binary has a compiled unit search directory under its own output:

/nix/store/...-systemd-259.3/lib/systemd/system

The packaged default units live under example/systemd/system, so Phase 213 also links the compiled path inside the image copy:

/nix/store/...-systemd-259.3/lib/systemd/system
  -> /nix/store/...-systemd-259.3/example/systemd/system

Without this, systemd can start but then fails with:

Unit multi-user.target not found.
Unit rescue.target not found.

Why this phase uses pinned pkgsMusl.systemd

The final ONIX shape should be:

onix-systemd.stone

That package should be built and owned by ONIX.

But we are not there yet. We still need to learn whether a musl systemd payload can actually cross the kernel/initramfs handoff in our image.

So Phase 213 uses a slim override of the pinned Nix package:

pkgsMusl.systemd

as a bootstrap/probe payload.

That means:

  • it must be musl-targeted
  • it must come from the pinned flake.lock
  • it is copied into the image intentionally and visibly
  • it is documented as a temporary probe layer
  • it does not replace the future onix-systemd stone

The override disables features that are not needed for the first PID 1 handoff:

documentation
BPF framework
TPM support
systemd-boot/UKI building
remote/import/homed/container extras
polkit/PAM/login-manager extras
networkd/resolved/timesyncd extras
compression/coredump/sysupdate/repart extras

This avoids dragging in a very large build graph for features we are not testing yet. The first question is simply:

can a musl systemd binary become PID 1 in our image?

Later phases can add features back intentionally.

One feature is intentionally not optional for this probe:

libmount

systemd uses libmount while setting up early API filesystems such as /run and cgroup mounts. Without libmount support, systemd can execute as PID 1 but fails almost immediately with messages like:

Failed to mangle mount options ... Not supported
Failed to mount API filesystems.

So Phase 213 forces the Meson option:

-Dlibmount=enabled

and verifies the resulting shared systemd library contains libmount.so.1 support instead of the fallback message:

libmount support not compiled in

There is one more musl-specific detail.

systemd lazy-loads libmount with:

dlopen("libmount.so.1")

For direct dependencies, Nix-style absolute RUNPATHs usually work well. Lazy dlopen is more subtle, because the dynamic loader has to discover a library by soname at runtime.

With a Nix-store musl loader, the loader path file is not simply:

/etc/ld-musl-x86_64.path

The loader path is derived from the interpreter prefix. For our payload the interpreter looks like:

/nix/store/...-musl-1.2.5/lib/ld-musl-x86_64.so.1

So the useful path file inside the image is:

/nix/store/...-musl-1.2.5/etc/ld-musl-x86_64.path

Phase 213 writes that file inside the image copy of the musl store path. It contains library directories from the copied runtime closure, including the util-linux directory that provides:

libmount.so.1

This does not mutate the host Nix store. It only changes the copy of the store paths inside the ONIX disk image.

This keeps the learning step small:

first prove systemd userspace can start
then package it properly as ONIX-owned stone content

Why copying a Nix closure is different from copying one binary

A dynamically linked Linux binary records the path to its program interpreter and shared libraries.

For a Nix-built musl binary, those paths usually look like:

/nix/store/...-musl/lib/ld-musl-x86_64.so.1
/nix/store/...-libcap.../lib/libcap.so...
/nix/store/...-systemd-259.3/lib/systemd/systemd

If we copied only:

/usr/lib/systemd/systemd

the kernel would find the file but the dynamic loader or libraries could still be missing.

So Phase 213 asks Nix for the runtime closure:

nix path-info -r <pkgsMusl.systemd output>

and copies those /nix/store/... paths into the image.

Why the script chooses the runtime output explicitly

Nix packages can have more than one output.

For example, a package may produce:

/nix/store/...-systemd-259.3
/nix/store/...-systemd-259.3-man

The -man output is documentation. It is not the runtime payload. It does not contain:

lib/systemd/systemd

So Phase 213 does not blindly trust the first path printed by nix build. Instead it checks each printed output path and selects the one that actually has the executable:

<output>/lib/systemd/systemd

That matters because the image needs the runtime output, not manuals or development extras.

Why the closure is copied twice

The early boot moment is subtle.

The kernel/initramfs mounts the ONIX root filesystem first. At that moment, before systemd has processed /etc/fstab, the path:

/nix/store

must already work from the root partition, because PID 1 may need libraries from there immediately.

Later, ONIX wants Nix to live persistently under:

/persist/nix

and bind-mount that onto:

/nix

So Phase 213 copies the systemd runtime closure into both places:

root partition:    /nix/store/...
persist partition: /persist/nix/store/...

That gives PID 1 access before fstab, and keeps the same paths available after the future /persist/nix -> /nix bind mount.

This is not the final storage model for every package. It is the first boot probe strategy for a Nix-built musl systemd payload.

What the image builder writes

make phase 213 updates the existing image:

artifacts/onix-image/onix.raw

It mounts:

ONIX-ESP
ONIX-BOOT
onix-root
ONIX-PERSIST

Then it stages:

/nix/store/<systemd runtime closure>
/persist/nix/store/<systemd runtime closure>
/usr/lib/systemd/systemd -> /nix/store/...-systemd-259.3/lib/systemd/systemd
/usr/lib/systemd/systemd-udevd -> /nix/store/...-systemd-259.3/lib/systemd/systemd-udevd
/usr/lib/systemd/system -> /nix/store/...-systemd-259.3/example/systemd/system
/nix/store/...-systemd-259.3/lib/systemd/system -> /nix/store/...-systemd-259.3/example/systemd/system
/usr/bin/systemctl -> /nix/store/...-systemd-259.3/bin/systemctl
/usr/bin/journalctl -> /nix/store/...-systemd-259.3/bin/journalctl
/nix/store/...-musl-1.2.5/etc/ld-musl-x86_64.path
/etc/machine-id
/usr/share/onix/bootstrap/systemd-payload.txt
/boot/ONIX/README.phase213

It also writes a new boot entry:

/boot/loader/entries/onix-phase-213.conf

and makes it the default in:

/efi/loader/loader.conf

Why /etc/machine-id is empty

/etc/machine-id is the machine identity file.

It must not be baked with the same non-empty ID into every image forever.

Phase 213 creates the file as an empty placeholder:

/etc/machine-id

That lets later boot policy decide whether systemd should initialize it on first boot or whether image assembly should seed it for a specific machine.

The important part in this phase is:

the path exists, but no shared permanent identity is baked in

What Phase 213 proves

make phase 213 proves:

  • the pinned pkgsMusl.systemd payload can be built or fetched
  • the payload contains lib/systemd/systemd
  • the payload contains lib/systemd/systemd-udevd
  • the payload is built with libmount support for early API filesystem setup
  • the payload contains example/systemd/system/multi-user.target
  • the Nix runtime closure can be copied into the image
  • the image copy of the musl loader has an ld-musl-x86_64.path file that can find libmount.so.1
  • /usr/lib/systemd/systemd exists from the ONIX root view
  • /usr/lib/systemd/system/multi-user.target exists from the ONIX root view
  • the compiled Nix store unit path also resolves multi-user.target
  • the persist partition also has the same closure for the future /nix bind
  • the bootloader default now points at onix-phase-213.conf

What Phase 213 does not prove

Phase 213 does not prove:

systemd starts successfully as PID 1
udev works
journald works
multi-user.target reaches login
networking works
the machine is usable

Those are exactly what the next boot probe should tell us.

What comes after 213?

Run the boot probe again:

make phase 212

or watch it directly:

ATTACHED=1 make phase 212

This time the expected question changes from:

can the kernel find /usr/lib/systemd/systemd?

to:

what is the next missing userspace dependency after systemd starts?

Phase 214 — first kernel module/kmod payload

At a glance

FieldValue
Phase familyPhase 2 — bootable image
Run commandmake phase 214
Underlying make target/scriptvm/phase2/build-image-skeleton.sh --module-payload
Runs onhost, then rootful image mount/copy work
Main proof/artifactStages the first matching kmod/modprobe and kernel module tree into the ONIX image.

Phase 214 answers the next problem discovered by Phase 212 after Phase 213.

Phase 213 made this part work:

kernel
  -> initramfs
  -> ONIX root filesystem
  -> /usr/lib/systemd/systemd
  -> multi-user.target

That is a real boot milestone. systemd became PID 1 and reached the normal multi-user target.

But the boot log also showed warnings like:

Unable to locate executable 'modprobe': No such file or directory

and the /boot or /efi mounts could still fail or time out.

Those two things are connected.

What modprobe is

The Linux kernel can have functionality in two broad forms:

built into the kernel image
loadable as a kernel module

If something is built in, it is already available as soon as the kernel starts.

If something is a module, userspace may need to ask the kernel to load it.

The normal tool for that is:

modprobe

modprobe is not magic by itself. It reads the module metadata under:

/lib/modules/<kernel-release>/

and then asks the kernel to load the right .ko module files.

Because ONIX uses a merged /usr layout:

/lib -> /usr/lib

the module tree can physically live at:

/usr/lib/modules/<kernel-release>/

and still be visible through:

/lib/modules/<kernel-release>/

Why this matters for /boot and /efi

The ONIX image uses:

ONIX-ESP   -> /efi   -> vfat
ONIX-BOOT  -> /boot  -> vfat

vfat is the common FAT filesystem variant used by EFI system partitions.

Mounting a vfat filesystem may require these kernel modules:

fat.ko
vfat.ko
nls_cp437.ko
nls_iso8859-1.ko

During the initramfs stage, these modules already exist. That is why the early boot environment can understand the disk well enough to find and mount the real root filesystem.

But after switch_root, the old initramfs filesystem disappears.

The real ONIX root then needs its own copy of:

modprobe
/lib/modules/<kernel-release>

otherwise systemd can ask for module loading and fail because the tool or module tree is missing.

Why this phase extracts from the initramfs

Phase 211 already installed this pair:

/boot/ONIX/vmlinuz
/boot/ONIX/initramfs.img

Those two artifacts came from the forge VM together. The initramfs contains the module tree that matches the kernel it boots.

That means the safest bootstrap source for Phase 214 is not a random package download. It is the already-booted initramfs:

vm/state/initramfs-virt

Phase 214 extracts from that initramfs and stages the relevant files into the real image root.

The key copied paths are:

/usr/bin/kmod
/usr/bin/modprobe -> kmod
/usr/sbin/modprobe -> ../bin/kmod
/etc/systemd/system/modprobe@.service.d/10-onix-modprobe.conf
/usr/lib/modules/6.18.38-0-virt

The exact kernel release is discovered from the initramfs module tree. The script expects exactly one release directory so it does not accidentally mix modules from multiple kernels.

The source modules in the Alpine-derived initramfs are gzip-compressed:

vfat.ko.gz
fat.ko.gz
fuse.ko.gz
configfs.ko.gz

Phase 214 does not leave them compressed in the ONIX root. It decompresses them to plain ELF kernel objects:

vfat.ko
fat.ko
fuse.ko
configfs.ko

Then it regenerates modules.dep with depmod.

That extra step matters because the temporary Nix-provided systemd closure links against a libkmod build that supports xz and zstd compression, but not gzip. Plain .ko files are the common format both sides can read.

Why kmod needs libraries too

Inside this Alpine-derived initramfs, modprobe is a symlink:

/usr/sbin/modprobe -> ../bin/kmod

and /usr/bin/kmod is a dynamically linked musl executable.

Phase 214 also exposes both common command names:

/usr/bin/modprobe -> kmod
/usr/sbin/modprobe -> ../bin/kmod

But there is a second subtle problem.

The upstream modprobe@.service unit uses a relative command:

ExecStart=-modprobe -abq %i

That only works if systemd’s executable search path contains the directory where modprobe lives. Our bootstrap systemd comes from a Nix musl closure, so its search behavior is not something ONIX should leave implicit.

Phase 214 therefore adds a small drop-in:

/etc/systemd/system/modprobe@.service.d/10-onix-modprobe.conf

with:

[Service]
ExecStart=
ExecStart=-/usr/sbin/modprobe -abq %i

The first ExecStart= line clears the inherited command. The second line adds the same command back, but with an absolute ONIX path.

That means copying only modprobe is not enough. We also need systemd to know exactly which modprobe to run during this bootstrap stage.

The executable also needs its dynamic loader and libraries, such as:

/usr/lib/ld-musl-x86_64.so.1
/usr/lib/libc.musl-x86_64.so.1
/usr/lib/libzstd.so.1
/usr/lib/liblzma.so.5
/usr/lib/libz.so.1
/usr/lib/libcrypto.so.3

Because ONIX has:

/lib -> /usr/lib

the interpreter path:

/lib/ld-musl-x86_64.so.1

is satisfied by installing:

/usr/lib/ld-musl-x86_64.so.1

This is still a bootstrap payload. It is not the final package story.

What this phase verifies

make phase 214 verifies:

  • the Phase 211 kernel/initramfs payload exists
  • the Phase 213 systemd payload exists
  • the initramfs contains exactly one module release directory
  • usr/bin/kmod exists in the initramfs
  • usr/sbin/modprobe points to ../bin/kmod
  • the module tree contains vfat, fat, and the common FAT NLS modules
  • the image receives /usr/bin/kmod
  • the image receives /usr/bin/modprobe
  • the image receives /usr/sbin/modprobe
  • the image receives the modprobe@.service drop-in
  • the image receives the matching /usr/lib/modules/<release> tree
  • gzip-compressed source modules are converted to plain .ko files
  • depmod regenerates modules.dep for the converted module tree
  • /lib/modules/<release> works through the merged /usr symlink
  • a chrooted kmod --version can execute
  • chrooted /usr/bin/modprobe, /usr/sbin/modprobe, and /sbin/modprobe can resolve vfat module dependencies
  • the Nix-provided bootstrap modprobe, if available from the systemd closure, can also resolve vfat against the ONIX root

That last check is important. It does not load a module on the host. It only asks modprobe to show what it would load for the ONIX kernel release.

Why /boot and /efi still use nofail

Phase 214 gives systemd a real chance to mount the FAT boot partitions cleanly.

But ONIX is still in bootstrap territory. We are proving one boot layer at a time.

The fstab entries currently include:

nofail,x-systemd.device-timeout=10s

That means a failure to mount /boot or /efi is recorded, but it does not prevent the machine from reaching:

multi-user.target

This is intentional while we are still building the base.

Once the module stack and device timing are consistently clean, a later phase can decide whether /boot and /efi should become strict mounts again.

What this phase does not solve yet

Phase 214 does not create the final ONIX kernel package.

It does not create the final ONIX kmod package.

It does not create the final ONIX initramfs generator.

It imports a known-good bootstrap payload so we can keep moving:

prove boot behavior first
package it properly second

Later ONIX should replace this imported payload with ONIX-owned stones, likely something like:

onix-kernel
onix-kernel-modules
onix-kmod
onix-initramfs

The exact names can change. The ownership boundary is the important part.

Expected result

After running:

make phase 214

the image should contain the first real module-loading payload and the boot menu default should move to:

ONIX (phase-214)

Then run:

make phase 212

to boot-probe the result.

The next thing to look for in the serial log is whether /boot and /efi mount more cleanly, and whether the previous modprobe missing warnings disappear.

Phase 3 overview — ONIX-owned kernel work, intentionally deferred

Phase 3 is reserved for the kernel/initramfs/modules story.

It is intentionally not the next implementation lane.

Why reserve a whole phase?

The kernel is not just “one more package”.

A Linux kernel payload is a bundle of tightly-coupled pieces:

kernel image
  + exact module tree
  + initramfs
  + firmware policy
  + bootloader entries
  + update/rollback rules

Those pieces have to match each other exactly.

For example, if the booted kernel is:

6.18.38-0-virt

then the module loader expects:

/usr/lib/modules/6.18.38-0-virt

If the kernel and module directory do not match, modprobe can fail even though the filesystem looks populated.

The initramfs has the same coupling. It must know how to find and mount the real root filesystem before systemd ever starts.

That makes kernel ownership a large, self-contained project. Mixing it into the base userspace work would make the learning path much harder to follow.

Current Phase 2 compromise

Phase 2 proved that ONIX can assemble and boot a real disk image. To keep that proof focused, it borrowed the kernel payload from the Alpine forge:

vm/state/vmlinuz-virt
vm/state/initramfs-virt

Phase 214 also borrowed the matching module tree from that same initramfs.

That is acceptable for the current boot proof because:

  • the kernel and modules match each other
  • the initramfs already knows how to mount the ONIX root
  • it lets us test systemd-on-musl and the image layout now

It is not the final ONIX design.

What Phase 3 will eventually own

Later, Phase 3 should replace the borrowed Alpine payload with ONIX-owned artifacts:

  • an ONIX kernel config
  • an ONIX kernel build recipe
  • an ONIX module package
  • an ONIX initramfs generator path
  • boot entry generation/update rules
  • rollback rules for kernel and userspace together

The goal is not merely “compile Linux”. The goal is:

ONIX can update and roll back kernel + initramfs + modules coherently.

What Phase 3 does right now

For now, Phase 3 has one documentation/checkpoint step:

Running:

make phase 3

prints the deferment clearly and points the work forward to Phase 4.

Why Phase 4 comes next

The current ONIX image already reaches systemd multi-user mode.

The next useful learning step is not compiling a kernel. The next useful step is making the booted image into a usable base system:

  • live /etc materialization
  • users/login/shell basics
  • serial login
  • networking
  • remote inspection

That is Phase 4.

Phase 300 — deferred kernel ownership contract

ItemValue
Commandmake phase 300
Underlying make targetvm/phase3/Makefile, target kernel-deferred
Mutates disk/image?No
Boots QEMU?No
Main proofThe project explicitly reserves kernel ownership for later instead of mixing it into Phase 4.

What this phase means

Phase 300 is a deliberate pause.

It records that ONIX currently boots with a borrowed Alpine virt kernel payload, and that this is a temporary bootstrap choice.

The borrowed files are:

vm/state/vmlinuz-virt
vm/state/initramfs-virt

Phase 214 extracts the matching module tree from that initramfs so the booted system has:

/usr/lib/modules/<borrowed-kernel-release>

This is internally consistent. That is why it works.

But it is not ONIX-owned yet.

Why not build the kernel immediately?

Because kernel work has a different risk profile from userspace work.

Userspace phases can usually be changed incrementally:

add file
boot
inspect log
fix service
boot again

Kernel ownership has tighter coupling:

kernel config
  must match modules
  must match initramfs
  must match root filesystem drivers
  must match bootloader entries

If any part is wrong, the failure may happen before systemd starts, before SSH starts, and sometimes before the logs are pleasant to read.

So Phase 3 gets its own lane.

What the eventual Phase 3 should answer

Later Phase 3 should answer:

  1. Which kernel source and version does ONIX track?
  2. How is the kernel configured?
  3. How are modules packaged?
  4. How is the initramfs generated?
  5. Which files are written to /boot?
  6. How are old kernel generations retained or pruned?
  7. How does a moss rollback relate to a kernel rollback?
  8. How do we prove kernel, initramfs, and modules are from the same generation?

Until those answers exist, the Alpine payload is a useful bootstrapping tool.

What success looks like right now

Run:

make phase 300

Expected output:

Phase 3 is intentionally reserved for ONIX-owned kernel/initramfs/modules.
...
For now, continue with Phase 4: booted ONIX base userspace.

That is all this phase should do today.

What comes next

Continue with:

make phase 400

Phase 400 starts the booted-base lane. It assumes the Phase 2 image can boot with the temporary borrowed kernel payload.

Phase 4 overview — booted ONIX base userspace

Phase 4 starts from the Phase 2 boot proof.

Phase 2 answered:

Can ONIX assemble a disk image and reach systemd multi-user mode?

The answer is now yes.

Phase 4 asks a different question:

Can the booted image become a usable base system?

What “base userspace” means

The kernel gets the machine started, but userspace is what makes it usable.

After the kernel mounts the real root filesystem, it starts PID 1:

/usr/lib/systemd/systemd

From that point onward, userspace is responsible for:

  • creating or validating /etc state
  • starting udev
  • discovering disks and network devices
  • mounting local filesystems
  • creating users and groups
  • starting login prompts
  • starting SSH or other remote inspection tools
  • preserving persistent state under /persist

Phase 2 proved the minimum handoff.

Phase 4 makes that handoff useful.

The important boundary with Phase 3

Phase 4 does not own the kernel.

For now the image keeps using the borrowed Alpine virt kernel/initramfs/module payload proved in Phase 2:

vm/state/vmlinuz-virt
vm/state/initramfs-virt

That lets Phase 4 focus on the booted system itself:

/etc
/usr
/persist
/home
/nix
systemd units
users
login
networking

Kernel ownership remains reserved for Phase 3.

Initial Phase 4 direction

The first Phase 4 subphases should be small and observable.

Proposed path:

400 — Phase 4 readiness and direction
401 — materialize live /etc from /usr/share/defaults
402 — create base users/groups/login shell policy
403 — prove serial login
404 — add minimal networking inspection
405 — add SSH or another remote inspection path

The exact list can change as we learn, but the theme should stay stable:

make the booted image inspectable, login-capable, and base-system shaped

What Phase 4 should not do yet

Phase 4 should avoid:

  • building the ONIX kernel
  • designing the desktop stack
  • adding Nix integration
  • solving Mesa/graphics
  • making a huge package set

Those are later phases. The base system must become understandable first.

Steps

Phase 400 — booted-base readiness

ItemValue
Commandmake phase 400
Underlying make targetvm/phase4/Makefile, target readiness
Mutates disk/image?No
Boots QEMU?No
Main proofPhase 4 direction is explicit before we start changing the booted image.

What this phase does

Phase 400 is the Phase 4 doorway.

It does not install packages, edit the image, or boot QEMU. It prints the direction for the next implementation lane.

That matters because Phase 4 starts after a big milestone:

ONIX now boots to systemd multi-user mode.

A booting system is exciting, but it is still not a usable system.

What we have at the start of Phase 4

From Phase 2:

  • an ONIX raw disk image
  • GPT partitions with ONIX labels
  • systemd-boot/BLS boot menu
  • borrowed Alpine virt kernel/initramfs payload
  • first musl systemd userspace payload
  • matching kmod/module payload
  • successful QEMU boot probe

The important proof from Phase 212 after Phase 214:

/boot mounted
/efi mounted
/persist mounted
/home mounted
/nix mounted
systemd reached Multi-User System

That is enough to start base userspace work.

What is still missing

The image can boot, but it is still thin.

Examples of missing or early-stage areas:

  • live /etc is mostly assembled by image scripts, not a clear ONIX policy yet
  • users and login policy are not a proper ONIX base story yet
  • serial login needs to become an intentional proof, not an accident
  • networking is not yet an ONIX-owned base behavior
  • remote inspection is not yet a stable interface
  • base service units are still mostly inherited from the temporary systemd payload

Those are Phase 4 problems.

Why /etc comes first

Most real Linux behavior eventually touches /etc.

Examples:

/etc/os-release
/etc/fstab
/etc/passwd
/etc/group
/etc/shadow
/etc/shells
/etc/hostname
/etc/systemd/system
/etc/ssh

ONIX wants package-owned defaults under:

/usr/share/defaults

and live machine configuration under:

/etc

Phase 4 should make that relationship explicit.

The first real implementation after Phase 400 should probably be Phase 401:

materialize live /etc from /usr/share/defaults

What success looks like

Run:

make phase 400

Expected result:

Phase 4 starts from the Phase 2 boot proof.
...
Kernel ownership remains reserved for Phase 3.

Then we can begin designing 401 as the first real booted-base mutation.

Reminder

Phase 4 does not remove the Alpine kernel payload.

That replacement belongs to Phase 3 later. Phase 4 keeps using the borrowed payload so we can focus on userspace.