ajax80/schema-init

A single static binary as PID 1 for Linux — services supervised by a weight-state machine, no systemd. Reclaims its satellite daemons too, as native opt-in replacements: logind, udev, dbus, journald. PID 1 holds 1–4 MB RSS in one thread, frees ~500 MB RAM, and idle cores reach 92–99% C10 deep sleep.

C

2

268 commits

updated Oct 7, 2026

See the code

See what people are saying

SourceMessageScoreDate

schema-init: a PID 1 written in C that boots Fedora 44 KDE. It imports your systemd units and keeps systemd in GRUB as a fallback (installer ISO) (r/linux)

I'm a plumber by trade. After I became disabled I started dabbling in code, and over the past few months that turned into schema-init, an init system (PID 1) written in C that I build and run on Fedora. To be upfront: I design it, run it on my own machines and break it on real hardware, and Claude…

0

Oct 7, 2026

README

schema-init

schema-init — 30-second trailer

▶ 30-second trailer — watch in full resolution, with sound

A minimal PID 1 init system for Linux that supervises services through a weight-state machine instead of unit files and dependency graphs — and, increasingly, a native, auditable replacement for the systemd daemons around it.

No systemd. No OpenRC. No journal daemon. No socket activation engine. At its core, just a statically linked binary that mounts your filesystems, spawns your services in dependency order, and watches them — then gets out of the way.

It doesn't stop at PID 1. systemd's satellite daemons don't have to be systemd's. schema-init ships small, single-purpose, native replacements you opt into one at a time: schema-logind (sessions, power, seats — plus the hostname1/timedate1/systemd1 D-Bus surfaces), schema-udev (device management, authoritative over /dev), schema-journal-sink (a journald-shaped endpoint that drains to a plain logfile — no journal database), built-in .svc timers that retire cron and systemd .timer units, and now schema-dbus — the D-Bus broker itself, serving both the system bus and the per-user session bus. Each is a program you can read end to end, runs only if you list it, and backs out with a single reboot. So you can reclaim the init layer piece by piece — or keep the stock daemons underneath and just run a leaner PID 1. The point isn't only less; it's an init layer you can actually read, top to bottom, and own.

PID 1 footprint: 1.2 MB RSS on a minimal boot, 3.3–4.0 MB running a 47-service KDE desktop — one thread, in every case. Every footprint figure in this README names the machine, the build and the service count it was measured on: see PID 1 RSS — every measurement.


What it gives back

systemd isn't just PID 1 — it's a constellation of always-on daemons: journald, systemd-logind, dbus-broker, systemd-resolved, resident udevd workers, timers firing on their own schedule. Each one holds RAM and wakes the CPU whether or not you're using it. schema-init replaces PID 1 with a single static binary and does none of that — no journal database, no socket-activation engine, no background event loops. What that machinery was holding comes back to you.

Your RAM comes back. On identical hardware running the identical desktop, schema-init frees roughly half a gigabyte of RAM that systemd's daemon stack was sitting on (~1.1 GB used at desktop vs ~1.6–2.0 GB — see Real numbers), and idle swap drops from hundreds of MB to zero. In lived terms that is the difference between a few browser tabs plus one other program before the machine starts thrashing and two or three browsers with ~20 tabs each and a game running at the same time — same RAM, no upgrade. The computer you already own effectively gets bigger.

Your power comes back. With no ambient timer wakeups holding the cores awake, the CPU actually reaches its deepest hardware sleep state: measured 92–99% C10 residency and ~1.25 W full-SoC package draw at a working desktop, idle load average 0.03 (vs 0.10–0.20 under systemd). Those figures are read from Intel RAPL hardware energy counters, not estimated. Per machine it is a small, honest number — but it is structural, paid back every second of every idle hour. schema-init's part is simply removing the constant wakeups that keep silicon out of deep sleep in the first place.

On extrapolating this: don't. These are single-node measurements on one i3 laptop. An init system's own power draw is a tiny slice of a server's total, so multiplying a per-node idle delta by a fleet size produces a number that will not survive contact with anyone who runs real hardware. If schema-init saves money at scale, the levers are density, footprint, boot time, attack surface and determinism — not init power draw.

The machine goes quiet, not just lean. One PID-1 thread instead of 20–30. A tick loop that sleeps indefinitely once services are stable — nothing wakes it on a schedule. No journal flush, no D-Bus polling, no watchdog chatter. The hardware is allowed to actually rest.

This isn't theory or a benchmark rig — it's a salvaged Dell Inspiron (Intel i3, 4 GB) that swapped constantly under systemd and now runs a full desktop with room to spare under schema-init. Older and low-RAM machines benefit the most: the daemons you delete are the exact ones a small machine can least afford.


How it works

Every service moves through a state machine driven by probes. Before a service is spawned, schema-init probes the system — is the binary present? Are dependencies stable? Is there enough memory? The probe returns a flag word. The state machine decides what comes next.

                  ┌─────────────────────────────────────┐
                  │                                     │
            NEW_PROCESS                                 │
                  │                                     │
           F8 probe passes                              │
                  │                                     │
            FULL_TRUST ──── stable 10s ──── FUNDAMENTAL │
                  │                         SETTLED     │
                  │                                     │
             (oneshot exit 0)                           │
                  │                                     │
              PERFECT                                   │
                                                        │
            ── on death ──                              │
                  │                                     │
             RECOVERY ◄──────────────────────────────── ┘
                  │
           F9 probe fails
                  │
             FRICTION
                  │
           F6 probe fails
                  │
             DORMANT  (75 — backoff anteroom: 5m → 10m → 20m → 40m → 60m)
                  │
         (non-critical, 5 cycles exhausted)
                  │
             EXCISED  (76 — gate closes)

Three probe families:

ProbeAsked whenChecks
F8Before first spawnBinary exists, deps stable, memory safe, permissions met
F9After deathRetry budget, cooldown window, memory, escalation path
F6After recovery failsLast-chance: can we even attempt a restart?

FULL_TRUST promotes to FUNDAMENTAL on the first readiness signal the service has: READY=1 over sd_notify (notify=1), its bus name appearing (ready_bus_name=), a path appearing (ready_path=), a forking daemon's launcher exiting 0 with a valid pid_file=, and otherwise surviving stable_secs (default 10).

Services marked critical=1 never reach EXCISED — they enter DORMANT and retry at 1-hour intervals indefinitely. Non-critical services excise after 5 dormant cycles (~75 minutes). A dep marked critical=1 that is EXCISED still blocks its dependents. A non-critical EXCISED dep is skipped — dependents proceed without it.


Quickstart

Replacing PID 1 sounds scary. It isn't, if you do it in the right order — you never lose your existing systemd boot, and you can back out with a single reboot at every step. Four lanes, safest first.

The easiest path — install from COPR (Fedora KDE)

On Fedora you don't have to build anything or write a USB stick. Enable the COPR and install:

sudo dnf copr enable ajax80/schema-init
sudo dnf install schema-init-migrate      # the CLI migrator + schema-udev, prebuilt
# optional GUI front-end:
sudo dnf install schema-init-wizard

This installs schema-init alongside systemd and changes nothing about how you boot — the packages just put the tooling on your system. To actually convert a Fedora KDE box, run the in-place migrator (proven end-to-end in a VM), which writes a non-default (schema-init) boot entry your normal Fedora still overrides:

sudo schema-migrate --discover                 # reads the system, changes nothing
sudo schema-migrate --deploy --prebuilt        # uses the packaged binaries, no compiler
# reboot, pick the (schema-init) entry; to undo:  sudo schema-migrate --uninstall

schema-init-wizard is a guided GUI wrapper around that same reversible flow with a two-reboot safety ladder. It's been VM-tested end to end (deploy, udev flip, dbus flip, and forced rollbacks of both) but hasn't yet been run on real hardware, so if you want the conservative route, use the schema-migrate CLI above; both do the same thing and back out the same way.

The COPR builds three packages: schema-init (the init), schema-init-migrate (the migrator + schema-udev), and schema-init-wizard (the GUI).

ajax80/schema-init only gets tagged releases. Every commit to master also builds into ajax80/schema-init-dev. That repo is untested and meant for the author's own machines, so don't enable it on a box you depend on.

The fast path — boot a prebuilt installer (no compiler, no Docker)

If you just want to see it run, grab the prebuilt Fedora 44 installer (schema-netinst44-installer-*.iso) from the latest release:

# download the .iso from the release, then:
sudo dd if=schema-netinst44-installer-<version>.iso of=/dev/sdX bs=4M status=progress oflag=direct && sync

Boot that USB stick (or point a VM at the ISO) and the installer gives you a full KDE desktop running schema-init as PID 1. It is a netinst image: the machine needs a network connection during install (it pulls the KDE package set). Verified on real hardware — a clean install on a Dell i3 laptop boots straight to Plasma with no hand fixes.

The install is package-managed: schema-init, -daemons, -session and -migrate are RPMs and the ajax80/schema-init COPR repo is enabled, so sudo dnf upgrade brings later builds and PID 1 re-execs onto them in place. -migrate also brings the flip tools and health checks (in /usr/libexec/schema-init), the systemctl stand-in, and the import of any systemd unit a later package installs. A box installed from the v0.4.1 ISO or earlier has the files but not the packages; convert it once, then reboot (the edited service files hold off reloads and re-execs until then):

sudo dnf copr enable ajax80/schema-init
sudo dnf install schema-init schema-init-daemons schema-init-session schema-init-migrate
sudo schema-ctl reexec && sudo rm -f /usr/local/bin/schema-ctl
sudo sed -i -E 's#^exec=/usr/local/lib/schema/(schema-dbus-run\.sh|schema-udev-flip-healthcheck\.sh|schema-dbus-flip-healthcheck\.sh)$#exec=/usr/libexec/schema-init/\1#' /etc/schema-init/services/*.svc
sudo sed -i 's#/usr/local/lib/schema/schema-flip-apply#/usr/libexec/schema-init/schema-flip-apply#' /etc/sudoers.d/schema-flip

The stock systemd boot entries stay in the GRUB menu as a fallback. SELinux is off while schema-init is PID 1, so files written then carry no labels, and the imported selinux-autorelabel-mark unit flags the disk for relabelling. The first boot of a systemd entry after running schema-init therefore relabels the whole filesystem and reboots once. That takes a few minutes and is expected; it is what makes the fallback safe to use.

After the first login a wizard offers the optional guided udev cutover — that step retires systemd-udevd and hands /dev to schema-udev. Wi-Fi and wired profiles are unpinned from systemd's interface names before the switch, so the network survives it. Once that's confirmed healthy, it offers a second optional step on its own reboot: switching both the system and session bus to schema-dbus, with the same automatic rollback if the bus doesn't come up.

⚠️ What the udev flip does: it kills systemd-udevd and makes schema-udev authoritative over device management. This is the whole point — watching your init own /dev — but it is a real change to how the box handles hardware. It's optional and guided; skip it and you still get schema-init as PID 1 with stock udev underneath.

After the restart, just log back into your desktop — it finishes the switch on its own; you don't have to click anything. If you arm the switch but then never return to the desktop, a headless seatbelt safely undoes it after the next boot and puts you back on stock udev — no damage, nothing to clean up. (One consequence of that safety net: it's a desktop on-ramp — a machine you run headless won't keep the flip, because the confirmation comes from the graphical session coming up.)

Prefer to build it yourself, or try it with zero risk to a real disk first? Take the lanes below instead — they compile from source and boot in a throwaway VM.

Requirements:

  • Build + test (Lane 0): gcc, make, pkg-config, libacl headers (libacl1-dev on Debian/Ubuntu, libacl-devel on Fedora — the udev uaccess tests link -lacl), and dbus-1 headers (libdbus-1-dev on Debian/Ubuntu, dbus-devel on Fedora — the default make target builds schema-dbus and make test compiles the sdbus tests against them). On Fedora the static link also needs glibc-static (Debian bundles libc.a in libc6-dev). Nothing else. The init itself is a single static binary with no runtime dependencies.
  • schema-logind: additionally python3-dbus + python3-gobject.
  • Build a bootable ISO (Lane 1): additionally Docker (or podman) — the ISO is built from a debian:bookworm container — plus squashfs-tools (mksquashfs), and network access to pull the base image. To boot that ISO in a window: qemu-system-x86_64, xorriso, socat.

Lane 0 — Build and test it (needs only a compiler, zero risk)

git clone https://github.com/ajax80/schema-init && cd schema-init
make            # build the static binary
make test       # ~30 unit tests: schema state machine, cgroup tiering, udev parity, …

No root, no Docker, no VM — this just proves the code compiles clean and passes its test suite on your machine. Start here.

Lane 1 — Watch it boot in a VM (still zero risk to your machine)

sudo scripts/make-iso.sh              # build a bootable schema-init ISO (needs Docker + squashfs-tools)
scripts/vmtest-gui.sh boot ~/schema-init.iso   # boot that ISO in QEMU (needs qemu + xorriso + socat)

make-iso.sh builds a full Debian + desktop live image with schema-init as PID 1 — it pulls a debian:bookworm container, so Docker must be installed and running and the first build downloads a few hundred MB. vmtest-gui.sh then boots the ISO you just built (it takes the ISO path as an argument — build it first). Nothing here touches your real bootloader or /dev; this is how you see it boot a real system before you trust it with yours.

Lane 2 — Install alongside systemd (reversible)

sudo ./setup.sh

The installer does not replace systemd. It compiles, installs the binaries, and:

  • installs a mount-fstab service that mounts your disks, swap, and bind mounts from /etc/fstab, read fresh at every boot (schema-mount-fstab: parents first, waits for each device up to x-systemd.device-timeout= — 90 s, or 10 s with nofail — and a nofail entry never fails the boot; noauto and _netdev entries are skipped). scripts/gen-mounts.sh writes the service plus a mount-fstab.sh snapshot to check;
  • optionally imports your enabled systemd services as .svc stubs so the box comes up running what it ran before (scripts/gen-services.sh);
  • writes a separate schema-init (fallback) GRUB entry and leaves stock systemd as the default.

Reboot, pick schema-init (fallback) from the boot menu, and try it. If anything is wrong, reboot and choose your normal systemd entry — you're back, untouched. Iterate on your service files, boot the schema-init entry again. In this lane systemd-udevd still runs; schema-init does not retire anything.

Run scripts/gen-mounts.sh (preview) before rebooting and read the mount-fstab.sh snapshot — confirm every mount is right. It shows what your /etc/fstab decides; the boot reads the fstab itself, so fix any mistake there.

Fedora KDE — the in-place migration wizard (turnkey Lane 2)

If your daily driver is Fedora KDE, schema-migrate is Lane 2 tuned for exactly that: it converts your running install in place, keeps your desktop working, and reverses with one command. It reads your system, builds and installs schema-init, ports the KDE session seam (seatd, schema-logind, autologin, PipeWire, polkit), bridges udev so the network and /dev come up, writes a non-default (schema-init) GRUB entry (your normal Fedora stays the default), makes the boot menu visible so you can pick it by hand, and heals the session gaps on first boot with schema-doctor.

git clone https://github.com/ajax80/schema-init   # e.g. onto a USB stick
cd schema-init
# preview only — reads the system, changes nothing:
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --discover
# deploy (builds from source; pulls gcc/make via dnf the first time):
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --deploy
# or, if schema-init is already installed (e.g. from the COPR package),
# skip the compiler entirely and use the packaged /usr/bin binaries:
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --deploy --prebuilt

Reboot, pick the entry ending (schema-init) from the boot menu. To go back: boot your normal Fedora entry, then run the same command with --uninstall — it reverses exactly what it wrote (manifest-tracked) and leaves pre-existing packages alone.

Proven end-to-end in a Fedora-KDE VM (legacy BIOS): deploy → schema-init as PID 1 with a full Plasma desktop → --uninstall → back on systemd. v1 is Fedora KDE only; other distros and desktops are the next milestone. The generic, distro-agnostic version of this path is Lane 2 above (./setup.sh).

Lane 3 — Make it the default (once you trust it)

When the schema-init entry has booted cleanly a few times, make it default (set GRUB_DEFAULT / your distro's boot-entry default to it). Only then, if you want the full reclamation, opt into the authoritative udev cutover — a deliberate, checksum-backed, reversible flip that retires systemd-udevd. It is the advanced path; validate it in schema-vmtest LIVE mode first.

A one-reboot undo (btrfs roots). On a btrfs root, make safe-install snapshots / and /home into a writable sibling subvol with its own boot entry before installing — so a deploy that breaks the desktop is a 30-second rollback (pick the snapshot entry in GRUB, reboot) rather than a live-debugging session. No initramfs overlay needed. An optional boot-success guard (schema-bootok plus a grub.d hook) ties "the boot succeeded" to the desktop actually came up — not just PID 1 finishing — and auto-selects the last known-good snapshot after a failed boot. Setup and deploy flow: docs/boot-success-guard.md.

Porting to a machine that isn't yours yet (e.g. setting it up for someone else): on that machine, while it's still on systemd, run ./setup.sh --generate-profile <name> (no root, installs nothing) — it captures the machine's mounts and enabled services into a reusable distros/<name>/ profile. Commit it, then sudo ./setup.sh --profile <name> brings the box up with its own disks and services. See Porting to a new distro and the distros/ profiles.


Repository layout

If you're reading the source to evaluate it, start here. PID 1 is ~4,800 lines of C (the files below plus caps.c, ns.c, landlock.c and a few headers) with no external dependencies.

Read these first, in this order:

FileLinesWhat it is
init.c~2,400PID 1 itself. Mounts pseudo-filesystems, reaps children, runs the supervise loop, handles signals and shutdown. The spine — everything below is called from here.
schema.c / schema.h~140The weight-state machine. Pure state transitions; a service's "weight" is the popcount of its probe flag word. This is the schema — the single source of truth for what every state means.
service.c / service.h~1,650Parses .svc files, spawns services, runs the F8/F9/F6 probes, and drives the recovery → backoff → excision arc.
group.c / group.h~170Aggregates a .grp of services into one worst-case state, so a stack (network, display) promotes and fails as a unit.

Supporting binaries:

FileWhat it is
schema-ctl.cThe CLI client. Talks to PID 1 over the /run/schema-init.sock UNIX socket — schema-ctl status, restart, etc.
schema-subreaper.c~50-line helper that sets PR_SET_CHILD_SUBREAPER so a service can adopt its own orphaned grandchildren instead of dumping them on PID 1.
schema-journal-sink.cOpt-in Track B compatibility shim. Provides journald's three ingestion sockets (/dev/log, /run/systemd/journal/{socket,stdout}) and drains them to a plain logfile so foreign libsystemd/syslog software finds a journald-shaped endpoint. No journal DB, no journalctl. schema-init never needs it to boot. See docs/journal-sink-design.md.
schema-systemctl.c / systemctl_shim.hThe systemctl(1) compatibility shim. A drop-in that intercepts systemd verbs so packaged RPM/deb scriptlets succeed on a schema-init box: lifecycle verbs drive schema-ctl, enable/preset queue enable-intent to /var/lib/schema-init/pending.list for the importer (distros/fedora-installer/migrate/schema-import.py, which drains that queue and translates .service units into native .svc). See Running packaged software.
caps.c / ns.c / landlock.cService hardening applied in the child before exec: the keep_caps capability bounding set and no_new_privs (caps.c), the private mount namespace behind private_tmp / protect_system / protect_home (ns.c), and the landlock_ro / landlock_rw filesystem allowlist (landlock.c). See Hardening.
notify.hThe sd_notify readiness socket (/run/schema-init/notify): parsing, kernel-attested sender credentials, cgroup attribution.
schema-coredump.c / coredump.hThe core_pattern pipe helper that keeps crashes. See Crashes.
schema-udev.c + *_id.h, udev_*.h, disk_links.hThe native device manager. See schema-udev.
schema-dbus.c + sdbus_*.hThe native D-Bus broker for the system and session buses. See The system bus itself.
scripts/schema-snapshot, scripts/schema-bootokBoot snapshots and the boot-success guard (docs/boot-success-guard.md).
schema_shm.hThe shared-memory interface — PID 1 publishes live service state here so external tools can read it without polling the socket.
schema-board.cRead-only board that renders every service's weight-state in its LED colour, reading the shm export above rather than the control socket — so it keeps working when the socket or the desktop is wedged. --once prints one frame and exits. Reads a world-readable 0644 shm segment, so unlike schema-ctl it needs no root. --tty /dev/tty8 paints a dedicated console; note that VT switching does not currently repaint on a graphical system — see Recovery console. Increments 1–2 of the limp-mode recovery surface (docs/superpowers/specs/2026-06-14-limp-mode-design.md).

Directories:

DirWhat's inside
services/The reference service set — real .svc and .grp files for sshd, dbus, udev, network-manager, display-manager, and the network-stack / display-stack groups. Copy these as your starting templates.
desktop/schema-desktop.c — an SDL2 live visualizer that maps schema_shm.h into an 8-node grid and shows every service's weight-state in real time. This is how you watch the state machine run.
scripts/Build and integration tooling: make-iso*.sh / make-usb.sh / fix-usb.sh (bootable media), schema-logind.py (a logind compatibility shim), and verify_traceability.py (IEC 62304 requirement traceability).
distros/Per-distribution profiles — fedora-kde/ and raspberry-pi-zero-w/. Each carries the service files and boot glue that distro needs.
docs/, assets/Documentation and images.

Top-level: setup.sh (newcomer bootstrap — dep check, desktop-environment detection, GRUB entry generation) and Makefile (static build; see Building).


Service files

Drop a .svc file in /etc/schema-init/services/. One key=value per line:

name=sshd
exec=/usr/sbin/sshd
args=-D
needs_root=1
name=display-manager
exec=/usr/sbin/lightdm
dep=dbus
dep=udev
needs_root=1
critical=1
name=network
exec=/usr/local/bin/net-setup
oneshot=1

Keys:

KeyDefaultDescription
name(required)Service name — used in logs, dep resolution, and schema-ctl commands
exec(required)Absolute path to the binary to execute
args—Argument string (repeat the key for multiple args)
dep—Dependency by name (repeat for multiple deps; can name a service or a group)
oneshot0Exit 0 → PERFECT and don't restart; exit non-zero → RECOVERY arc
needs_root0Abort spawn if uid ≠ 0
critical0If 1: service never reaches EXCISED — stays DORMANT at 1h retry indefinitely. Also: if this service is EXCISED, its dependents are hard-blocked.
no_restart0Any death → EXCISED immediately; no recovery arc
max_restarts5Maximum number of crash-driven restarts before entering EXCISED or backoff. The first spawn, timer firings and schema-ctl restart do not count; a run of 5 minutes or more before dying resets the count
stable_secs10Seconds process must stay alive before FULL_TRUST promotes to FUNDAMENTAL. Set lower for fast services; use ready_path instead when possible
ready_path—Filesystem path that, when it exists, triggers immediate FULL_TRUST→FUNDAMENTAL promotion. Falls back to stable_secs if the path never appears. A file already at the path when the service spawns (left by a previous run) does not count until it is replaced or rewritten, so a respawn is not promoted on its predecessor's marker or socket. Directories are exempt. In FUNDAMENTAL it also acts as a liveness probe: if the path disappears, the service is killed and backed off. The disappearance check only arms once the path has been seen at least once — a service promoted by stable_secs before its path exists won't be falsely killed. For services slower than stable_secs to come up (e.g. NetworkManager writing resolv.conf), set stable_secs generously so promotion doesn't outrun the path.
oom_score_adj—Written to /proc/self/oom_score_adj before exec (-1000..1000); e.g. -900 keeps the kernel OOM killer off the bus, logind or udev. Every child inherits it, so never set it on a session launcher (sddm, autologin) or anything that spawns user apps — they would become unkillable. No value = the kernel default. schema-dbus resets its activated helpers to 0.
ready_bus_name—Promote FULL_TRUST→FUNDAMENTAL as soon as this well-known system-bus name gets an owner inside the service's cgroup (systemd Type=dbus / BusName=). The schema-dbus broker reports acquisitions to PID 1 over /run/schema-init/notify; PID 1 accepts such reports only from root running /usr/bin/schema-dbus. Falls back to ready_path, then stable_secs (e.g. with another bus broker).
notify0notify=1: the service gets NOTIFY_SOCKET (/run/schema-init/notify) and promotes FULL_TRUST→FUNDAMENTAL the moment it sends READY=1 (the sd_notify protocol, as systemd Type=notify daemons already do). A message counts only if the kernel-attested sender pid is in the service's cgroup; messages carrying fds are dropped. Falls back to ready_path, then stable_secs, so a daemon that never notifies behaves as before. STATUS= is recorded. A sender that has already exited can't be attributed (its pid may be reused), so a send-and-exit helper such as systemd-notify --no-block falls back too; plain systemd-notify --ready is attributed.
pid_file—For a daemon that forks into the background (systemd Type=forking with PIDFile=). When the launcher exits 0, PID 1 reads the daemon's PID from this file and supervises that process from then on: it counts as ready, its death enters the recovery arc, and stop and shutdown signal it. The PID must be alive and, when the service has a cgroup, inside it, so a stale or planted file is refused. A file written after the launcher exits is waited for up to 5 s; if none is valid by then the service's cgroup is killed and it retries. Needs the service's cgroup: without one there is nothing to check the PID against or to clean up with, so the service is excised (pid_file-no-cgroup).
watchdog_sec0Service watchdog (systemd WatchdogSec=). With notify=1 the service gets WATCHDOG_USEC/WATCHDOG_PID and must send WATCHDOG=1 (or be schema-ctl petted) at least every N seconds, counted from READY=1. A missed window sends SIGABRT (core dump via schema-coredump); still alive 90 s later, SIGKILL. The window is the one the running process was started with — a reload or re-exec that adds or changes watchdog_sec applies from the next spawn. A service being stopped is not watched. Either way it then restarts like any crash. The clock is held while the service is frozen under memory pressure. schema-import maps WatchdogSec= on Type=notify units. 0 = off.
watchdog_timeout_ms0Dead Man Token window in milliseconds. Service must call schema-ctl pet <name> (or send WATCHDOG=1) within this window or PID 1 stops kicking /dev/watchdog and the hardware resets. Use for critical=1 real-time processes. 0 = disabled.
cpu_limit0Percent of one CPU core (1–100) enforced via cgroupv2 cpu.max. Written before child exec. 0 = unlimited.
mem_limit0Memory hard cap in MB via cgroupv2 memory.max. OOM inside the cgroup kills the service, not the system. Written before child exec. 0 = unlimited.
prioritystandardCPU contention class via cgroupv2 cpu.weight: critical (weight 1000), standard (100), peripheral (10). Proportional share — only takes effect when cores are saturated; idle services are never penalized. The analog of systemd's CPUWeight=. Children inherit the service's cgroup, so tagging a session leader (e.g. display-manager) elevates its whole subtree, compositor included.
cpuset—CPU affinity list pinning the service to specific cores via cgroupv2 cpuset.cpus (e.g. 2,3 or 4-7). Constrains where the service may run, complementing priority/cpu_limit which govern how much. The analog of systemd's AllowedCPUs=. Requires the kernel's cgroupv2 cpuset controller (delegated automatically); if absent the setting no-ops. Empty = unconstrained (inherits the parent's CPUs). Useful for isolating a latency-sensitive control loop from cores that bursty background work hammers.
cpuset_partitionmemberExclusivity tier for the cpuset= cores via cgroupv2 cpuset.cpus.partition. member (default) = plain pinning, cores stay shared (no-op). root = the cores become an exclusive partition (no other service may run on them) while still being scheduler-load-balanced. isolated = exclusive and removed from the scheduler's load balancer — dynamic isolcpus=, no kernel cmdline needed; the target for a latency-critical control loop (the Ungulate Leg) or the audio path. Implemented as a cgroup v2 remote partition: schema-init reserves the cores in its own cpuset.cpus.exclusive and the service forms the partition, so the other services are unaffected. If the kernel rejects the partition (overlapping cores between two isolated services, or no cpuset controller) the service silently degrades to plain cpuset= pinning and a HAZARD line is logged — boot is never blocked. Requires a non-empty cpuset=; setting it alone is ignored with a warning.
allowed_slot_min-1Minimum hardware slot ID (inclusive) this service is permitted to run on. Checked against SLOT_ID env at spawn time. -1 = unconstrained.
allowed_slot_max-1Maximum hardware slot ID (inclusive). If SLOT_ID falls outside [allowed_slot_min, allowed_slot_max], spawn is refused with a HAZARD log and SVC_NO_RESTART is set — the service will not retry. Both min and max must be ≥ 0 to activate the gate.
on_boot_sec0Makes the service a timer: seconds after boot before the first fire (0 = at boot). Implies oneshot=1 — the service runs, exits, and re-arms. The analog of systemd's OnBootSec=. See Timers below.
on_active_sec0Timer period: seconds after each completion before the next fire. Measured from completion (like systemd's OnUnitInactiveSec=), so a slow run never overlaps itself. Implies oneshot=1.
start_timeout_sec90 for oneshots, 0 otherwiseMax seconds a service may sit in FULL_TRUST without promoting before it is killed and routed into the recovery arc — so a hung boot service can't stall its dependents. Defaults on for oneshots (the only services that can hang the chain; daemons promote via stable_secs). Timers are exempt (may run long). 0 disables. The analog of systemd's TimeoutStartSec=.
stop_timeout_sec3Seconds a service gets to exit after SIGTERM at shutdown before its cgroup is killed (1–300). Raise it for anything that saves state on the way out: the Plasma session gets 20, because plasmashell can need 12 s to finish writing a freshly built panel layout. The analog of systemd's TimeoutStopSec=.
stop_first0At shutdown, this service gets SIGTERM before any other, and nothing else is stopped until it has exited (or its stop_timeout_sec ran out). For the desktop session: its apps talk to system daemons on the way out (KDE's printer client to CUPS, for one) and hang if those are stopping at the same time. systemd gets the same effect by stopping user sessions before system services.
user—Run as this user (and its primary group, with its supplementary groups) instead of root. Resolved when the file is loaded.
env—KEY=VALUE set in the child before exec (repeat for several, up to 16). There is no shell, so no expansion.
no_excise0Like critical=1 for the excision gate only: the service backs off in DORMANT forever instead of reaching EXCISED, without hard-blocking its dependents the way a critical dep does.
fuse0Quarantine fuse: if any dependency enters FRICTION or EXCISED, kill this service and excise it at once (for something that must not run on a broken foundation).
fuse_cmd—Shell command (sh -c) run when the fuse trips.
failsafe—Shell command (sh -c) run when the service dies unexpectedly or hits start_timeout_sec: drive an actuator to a safe position, raise an alarm. $INSTANCE is set for template instances. One at a time per service.
failsafe_timeout_ms500The failsafe command is SIGKILLed after this long.
ready_poll_hzloop rateHow often a FUNDAMENTAL service's ready_path liveness check runs, when it should be slower than the main loop.
(default)Services restart automatically through the F9/F6 recovery arc unless no_restart or oneshot is set

A full example using readiness probes:

name=dbus
exec=/usr/bin/dbus-daemon
args=--system
args=--nofork
needs_root=1
stable_secs=2
ready_path=/run/dbus/system_bus_socket

Hardening

Per-service confinement, applied in the child between fork and exec. Each is opt-in per .svc; a host-wide switch can default them on.

KeyDefaultDescription
no_new_privs0PR_SET_NO_NEW_PRIVS: setuid binaries and file capabilities can no longer raise privilege in the service or anything it runs.
keep_caps—Comma-separated capability names (CAP_NET_BIND_SERVICE,CAP_SYS_TIME); every other capability is dropped from the bounding set. An unknown name refuses the load.
private_tmp0Fresh /tmp and /var/tmp for this service (private mount namespace).
protect_system01: /usr, /boot, /efi read-only. full: /etc too.
protect_home0/home, /root and /run/user replaced by empty read-only mounts.
landlock_ro / landlock_rw—One absolute path per line, repeatable (up to 16). If any is set, the service can reach only the listed paths (Landlock): _ro allows read and execute beneath the path, _rw allows everything. Root is confined too. The service's exec, its shared libraries and anything it reads (/etc/ld.so.cache, /proc, /dev/null…) must be listed. A path that doesn't exist is skipped. The load is refused if exec isn't covered, or if keep_caps drops CAP_SYS_ADMIN without no_new_privs=1 (the kernel requires one of them). Applied after the uid/gid lookups and just before setuid. In a drop-in, an empty landlock_ro= or landlock_rw= clears that list. Needs a kernel with Landlock enabled; without it the service fails to start.

Host default. /etc/schema-init/hardening-default containing on, or schema.hardening_default=1 on the kernel command line (which wins, both ways), turns no_new_privs, private_tmp, protect_system=1 and protect_home on for every service that doesn't set them. An explicit key=0 opts a service out. A defaulted private_tmp or protect_home that would hide the service's own exec or ready_path is dropped with a log line; an explicit one refuses the load. The switch is off unless you turn it on. schema-ctl status <svc> shows each knob's value and whether it came from the file, the default, or was dropped.

A hardening step that fails aborts the spawn (HARDENING FAILED in the service log) rather than running the service unconfined.

Service templates

For fleets of identical services — e.g. 49 joint controllers on an exoskeleton — define config once and symlink instances:

# template — write once
/etc/schema-init/services/motor@.svc

# instances — zero-byte symlinks; suffix becomes $INSTANCE in the child
ln -s motor@.svc /etc/schema-init/services/motor@0.svc
ln -s motor@.svc /etc/schema-init/services/motor@12.svc
ln -s motor@.svc /etc/schema-init/services/motor@48.svc

At boot, motor@.svc is skipped as a non-spawnable template. Each motor@N.svc symlink loads config from the template and spawns the binary with INSTANCE=N in the child environment. The motor controller reads $INSTANCE to determine its joint index, SPI bus address, or any other per-instance identity — no per-node config files required.

If a node runs the bare template directly (e.g. on a slot-detected Pi Zero W 2 where the node's identity comes from GPIO strapping), INSTANCE falls back to SLOT_ID from /run/schema-init/env. One SD card image serves the entire fleet.

AllowedSlot gate — for hardware deployments where running the wrong firmware on the wrong node is a physical hazard, add slot constraints to the template:

name=motor
exec=/usr/local/bin/motor-ctrl
allowed_slot_min=16
allowed_slot_max=27

If SLOT_ID is outside the declared range at spawn time, schema-init logs a HAZARD line, refuses the spawn, and sets SVC_NO_RESTART. The process never runs. Project Daedalus slot map:

Slot rangeJoint
0–7Hip Left
8–15Hip Right
16–21Knee Left
22–27Knee Right
28–33Ankle Left
34–39Ankle Right
40–43Toe Left
44–47Toe Right
48Supervisor

Dependencies are resolved by name at load time. A service stays in NEW_PROCESS until all its deps reach FUNDAMENTAL, SETTLED, or PERFECT. A dep name can refer to either a service or a group (see below).

Group files

Drop a .grp file in the same services directory to create a named group. Services can depend on a group name just like a service name.

name=storage
member=lvm
member=cryptsetup
member=mount-data

A group's state is the worst-case view of its members:

  • Any member EXCISED → group is EXCISED
  • Any member in FRICTION/RECOVERY → group reflects that
  • All members FUNDAMENTAL or better → group is FUNDAMENTAL
  • All members PERFECT → group is PERFECT

Maximum 16 groups, 8 members per group. Names and members are matched at load time.

Timers

Add on_boot_sec and/or on_active_sec to any .svc to make it periodic — no separate .timer file, no second unit to link. The service is the timer. This replaces cron and systemd .timer units with the same .svc you already wrote.

name=fstrim
exec=/usr/sbin/fstrim
args=-a
needs_root=1
on_boot_sec=600        # first fire 10 min after boot
on_active_sec=86400    # then every 24 h after each completion

A timer is a oneshot that re-arms on a CLOCK_MONOTONIC deadline instead of staying terminal at PERFECT:

  • It boots into PERFECT (as if it already ran), first fire at boot + on_boot_sec.
  • On fire it re-enters NEW_PROCESS — so dependencies are still honored and it waits for its deps exactly like any service.
  • When it exits, it re-arms for now + on_active_sec regardless of exit code (cron semantics — a failed run is not retried in a loop; it runs again next window). The exit is logged timer-done or timer-failed.

Run-once: set only on_boot_sec (leave on_active_sec unset) and the service fires exactly once, on_boot_sec seconds after boot, then stays terminal — a deferred startup job rather than a repeating one.

The period is measured from completion, so a slow job never overlaps itself. Fires on the 250 ms tick (±1 tick) — cron-class precision, not sub-second. For real-time work use watchdog_timeout_ms and the control loop instead.

Wall-clock timers — set on_calendar=HH:MM to fire at a fixed local time every day, on_calendar=Mon HH:MM for weekly, or on_calendar=15 HH:MM for a day of the month, the way you'd write a cron line. This is the form you want for "3am backup", "midnight log rotation", "nightly cert renewal":

name=nightly-backup
exec=/usr/local/bin/backup.sh
needs_root=1
on_calendar=03:00     # fire at 03:00 local time, every day

on_calendar re-evaluates the wall clock on every fire, so it tracks CLOCK_REALTIME (not monotonic) — DST shifts and NTP clock steps self-correct each cycle rather than drifting. Time is local (/etc/localtime). Malformed values are logged and ignored, never scheduled.

Catch-up after downtime — by default a job missed while the machine was off simply runs at its next occurrence. Add persistent=1 to run it once at boot instead, if its scheduled time passed while the system was down (systemd Persistent=true):

name=nightly-backup
exec=/usr/local/bin/backup.sh
needs_root=1
on_calendar=03:00
persistent=1          # if 03:00 was missed while off, run at next boot

Last-run is stamped to /var/lib/schema-init/timers/<name>.stamp; at boot, if the most recent scheduled occurrence is newer than that stamp, the timer fires immediately (logged timer-catchup) instead of waiting. A never-run timer is seeded rather than replayed, so enabling one doesn't trigger a surprise fire on first boot. persistent=1 only applies to on_calendar timers; on an interval timer it's logged and ignored.

Not yet implemented: several times per day, ranges and lists in one key. See docs/timers-design.md.


schema-udev

schema-udev is a native uevent→schema→action daemon — a small, purpose-built device manager that watches the kernel uevent stream and runs your hook scripts when a match fires. It runs in one of two modes.

Shadow mode (default)

The safe default: schema-udev runs alongside real systemd-udevd, not replacing it. udevd still owns /dev population, symlinks, and driver binding; schema-udev just watches the same kernel uevent stream and fires your rules for the specific devices you care about (an ESP32 over USB-serial, an RFID reader, a sensor board).

It binds kernel netlink group 1 (UDEV_MONITOR_KERNEL), never group 2 (UDEV_MONITOR_UDEV) — group 2 is udevd's own processed-event multicast, consumed by libudev/PipeWire for device enumeration. In shadow mode schema-udev only listens to the raw kernel group, so it has zero observed impact on PipeWire or desktop hotplug. Datagrams are checked against the kernel's SCM_CREDENTIALS (uid 0, pid 0) before being parsed, so a spoofed unprivileged uevent is dropped. Rule syntax, coldplug, and hooks below apply to both modes.

Authoritative mode (the udevd cutover)

The endgame of schema reclamation: schema-udev retires systemd-udevd and owns /dev itself. This is a deliberate, reversible opt-in — off unless you explicitly arm it — because it moves device management for the whole machine under schema-init.

  • Arm it by placing the sentinel flag /etc/schema-init/schema-udev.live and restarting the daemon; on the next launch schema-udev takes authority and systemd-udevd is retired. Its startup log records mode=LIVE (owns real /dev, /dev/disk, ACLs) so the mode is never ambiguous.
  • In LIVE mode schema-udev takes on everything libudev/PipeWire/logind expect from a udev: device-node creation (native mknod, RUN{builtin} kmod loads, ATTR{} sysfs writes), the /dev/disk/by-* + /dev/char + /dev/block symlink farm, uaccess seat ACLs, the group-2 libudev monitor broadcast, and the /run/udev/data device database — the two interop encoders that are dormant in shadow mode are wired live here.
  • Rules are interpreted natively from the distro's *.rules (TAG+=, SYMLINK+=, PROGRAM/RESULT, IMPORT, native *_id builtins) in addition to schema-init's own *.dev files, so tag/symlink-dependent consumers (FIDO2/pico-fido ACLs, snaps, power-button handling) keep working after udevd is gone.
  • Rollback is blessed and checksum-asserting: a verified pristine backup of the stock device path is restored on disarm (remove the sentinel), so a bad cutover reverts cleanly to systemd-udevd authority on the next boot. Keep the backup — the sentinel protects the machine; it does not un-take authority on its own.

⚠️ Authoritative mode is the advanced path. Validate it in schema-vmtest LIVE mode (prove it boots owning /dev) before trusting it on hardware you can't easily recover, and confirm the by-* symlink set your mounts depend on is complete for your disks.

Rule files live in /etc/schema-init/dev/*.dev, one key=value per line:

name=esp32-serial
match_subsystem=tty
match_product=10c4/*
symlink=esp32
on_add=/usr/local/bin/esp32-up.sh
on_remove=/usr/local/bin/esp32-down.sh
  • match_* keys map to raw kernel uevent keys (match_subsystem → SUBSYSTEM, match_product → PRODUCT, etc.), ANDed together — a rule only fires when every match_* key it declares matches. Values support fnmatch(3) globs (10c4/*).
  • symlink=<name> creates a stable symlink /dev/schema/<name> → /dev/<DEVNAME> on add, unlinking it on remove. Created atomically prior to on_add hook execution. Name must be single-level (no / or .., max 63 chars) under the parallel /dev/schema/ namespace to avoid writer contention with systemd-udevd.
  • on_add / on_remove are hook commands run via /bin/sh -c with the full uevent exported as environment variables — ACTION, DEVNAME, DEVPATH, PRODUCT, MODALIAS, and whatever else the kernel sent.
  • Coldplug at startup: On launch, schema-udev performs an in-process physical sysfs walk (/sys/devices) to synthesize events for devices already present at boot and fire on_add rules and symlinks without touching netlink or /sys/*/uevent files (ensuring zero systemd-udevd or desktop disturbance).
  • Comments must be on their own line (# as the first non-blank character). There is no inline-comment stripping — a trailing # note after a value becomes part of the value. See assets/example.dev (fully inert — every line commented, safe to drop in as a template) and copy it to /etc/schema-init/dev/<name>.dev to activate.
  • SIGHUP reloads all rule files from disk without restarting the daemon (schema-ctl reload or kill -HUP on its pid).
  • Raw kernel (group 1) uevents deliver DEVNAME without the /dev/ prefix (e.g. DEVNAME=ttyUSB0, not /dev/ttyUSB0) — don't anchor match_devname globs to /dev/, and hooks see $DEVNAME the same unprefixed way. Prefer keying on match_subsystem + match_product (vid/pid), as in the example above.

libudev / /run/udev interop

schema-udev carries pure encoders for the two formats a udevd retirement needs: the libudev monitor netlink frame (group 2) and the /run/udev/data device-database record, unit-tested against real captured frames. In shadow mode they are dormant — schema-udev neither broadcasts on group 2 nor writes /run/udev while systemd-udevd runs, since doing so would double libudev events and corrupt udev's database. In authoritative mode (see above) they are wired live: schema-udev is the sole writer of /run/udev/data and the sole group-2 broadcaster, which is what lets libudev/PipeWire/logind keep enumerating devices with udevd gone.


schema-doctor

schema-doctor is a self-healing seam checker. schema-init's replacements (its own logind, its own udev) meet desktop expectations at a handful of seams, and every machine comes up with different but very similar quirks clustered there — a compositor that raced ahead of its device ACLs, a power daemon that never autostarted, a menu-cache rebuild storm. The doctor diagnoses those seams, heals the safe ones itself, and names the deep ones (too invasive to fix under a live desktop — the real fix belongs at the source). It is a stdlib-only script, installed at /usr/local/bin/schema-doctor, that uses the still-installed systemd as its oracle for "what should this look like."

The checks

Each check is graded SAFE (auto-healed on every run) or DEFERRED (detected and named, never auto-healed — run with --force <name> to override):

CheckGradeCatches
card-input-aclSAFEactive user missing rw on a node the udev DB tags uaccess (drm card*, joysticks, SDR/FIDO) → re-applies the ACL
vt-mediationSAFECtrl+Alt+F<n> VT switching unmediated (the frozen-screen path) → re-arms it via schema-logind
session-singleDEFERREDan orphaned placeholder session — registration lost the boot race
login1-powerDEFERREDlogin1 not answering the PowerOff/Reboot/Suspend/Hibernate/inhibitor queries PowerDevil makes on load
powerdevil-runningDEFERREDPowerDevil not running, so power and screen-lock settings won't load
ksycoca-loopDEFERREDplasmashell and kded6 disagree on XDG_MENU_PREFIX, each rebuilding the menu cache in a loop

The run loop is the safety heart: snapshot → heal → verify, per check. If a heal doesn't resolve the fault, or breaks a previously-clean check, it is backed out and reported not-healed; a collateral break aborts the whole run. The entire run is wrapped to always exit 0 (critical=0) — the doctor can never block boot or leave the box worse than it found it. Config lives in /etc/schema-init/doctor.conf (heal=no for global detect-only, disable=a,b, notify=no).

Two runs, one engine

The same one-shot engine runs in two roles — it is not a daemon:

  • Boot run (schema-doctor.svc, --heal --wait 30): a late oneshot after a session exists, aggressive first-heal on a fresh box, silent.
  • Standing run (schema-doctor-periodic.svc, --heal --periodic): a native schema-init interval timer (on_boot_sec/on_active_sec, re-armed by PID 1) re-runs the engine every 10 minutes to catch faults that appear after login — a helper that dies at hour 3, an ACL a bad app clobbers mid-session.

The standing run adds three fail-safe guards:

  • Flap guard — heal history lives in /var/lib/schema-init/doctor-state (JSON, keyed by boot_id). A check healed ≥3 times in 30 minutes is marked CHRONIC: the doctor stops band-aiding it and escalates it to a visible RED, because the real fix is elsewhere. The mark clears the first time the check reads clean.
  • Health signal — /run/schema-init/doctor-status (text, plus a --json twin) is rewritten every run: per-check GREEN/AMBER/RED with an overall worst-wins rollup. schema-doctor --status prints it from anywhere.
  • Desktop notifications — edge-triggered on --periodic runs only: a check newly going CHRONIC or heal-failing, or the box returning to all-GREEN. Successful auto-heals are silent — quiet self-healing is the point. Notifications reach the active user by reading DBUS_SESSION_BUS_ADDRESS from the session leader's environ (this platform's bus is /tmp/dbus-XXXX, not /run/user/1000/bus), and are best-effort — a notify failure never fails a run.

schema-doctor is the logind-seam counterpart to what verify-rules-live is for the udev seam: a standing assertion that the seam still holds.


State glossary

StateMeaning
NEW_PROCESSQueued. Waiting for all deps to reach FUNDAMENTAL. No spawn attempt yet.
FULL_TRUSTSpawned. Watching — promotes to FUNDAMENTAL on READY=1 (notify=1), when its ready_bus_name gets an owner, when ready_path exists, or when stable_secs elapses, whichever comes first.
FUNDAMENTALStable. Load-bearing. Other services can depend on it.
SETTLEDStable, non-critical. Satisfies deps but generates no friction warnings if lost.
RECOVERYDied unexpectedly. F9 probe running. May re-queue or escalate.
FRICTIONRecovery failed. F6 last-chance probe running.
DORMANTF6 failed. Exponential backoff: 5m→10m→20m→40m→60m. Re-queues on wake. critical=1 services never leave this toward EXCISED.
EXCISEDPermanently removed. Non-critical only, after 5 dormant cycles. Gate closes.
PERFECTOneshot service exited 0. Terminal success.

Shutdown

schema-init handles shutdown signals from userspace or the kernel:

sudo kill -TERM 1   # poweroff
sudo kill -INT 1    # reboot

On SIGTERM, schema-init sets system state to shutdown, sends SIGTERM to all child processes, waits 500ms for clean exit, then calls reboot(RB_POWER_OFF).

On SIGINT, same sequence ends with reboot(RB_AUTOBOOT).

The 500ms hold is intentional — it gives any running desktop or display manager time to render a shutdown state before the process tree is torn down.


Known limitations

These are real gaps, not future features being teased:

  • Young, and not independently audited. schema-dbus and schema-logind sit on the system's authorization boundary — who may own a bus name, who may power off, who gets device access. Both are fuzzed and covered by tests, but neither has had an outside security review. Weigh that before running them on a machine other people log into.
  • No socket activation — services must manage their own sockets. There is no systemd-style socket hand-off (LISTEN_FDS).
  • Log rotation is not scheduled by default. The logrotate config ships, and an example timer (services/logrotate.svc.example) ships alongside it, but nothing fires the rotation until you enable that timer. See Logs.
  • schema-logind.py reimplements a subset of org.freedesktop.login1. It models multiple concurrent sessions and seats — a session registry with one object per session, per-seat membership, and active-session tracking — which is enough for a Wayland compositor to take KMS and hand it back on a VT switch (see Recovery console) and for uaccess device ACLs to follow the active session. It is a targeted reimplementation, not the full daemon. It also does not set KDSKBMODE = K_OFF, deliberately — if the daemon died while K_OFF were set the console keyboard would stay dead, and K_OFF would also disable the kernel's ctrl-alt-F VT switch that is the recovery-console escape hatch — so keystrokes still reach the tty underneath a compositor. They are neutralised rather than blocked: when the session VT is handed to the compositor, echo is turned off and pending input is flushed, and no getty runs on the session VT to read what arrives.

Filesystem setup

schema-init does not parse /etc/fstab. On boot it mounts the pseudo-filesystems directly:

MountTypeNotes
/remount rwKernel mounts rootfs read-only for fsck; schema-init remounts it writable before anything else
/procprocnosuid, nodev, noexec
/syssysfsnosuid, nodev, noexec
/devdevtmpfsnosuid, strictatime
/dev/ptsdevptsnosuid, noexec, gid=5,mode=620,ptmxmode=666 — without it there are no PTYs and every terminal emulator fails to start
/dev/shmtmpfsnosuid, nodev, mode=1777 — POSIX shared memory
/runtmpfsnosuid, nodev, mode=0755
/sys/fs/cgroupcgroup2nosuid, nodev, noexec, relatime

After mounting /dev, schema-init creates four symlinks that devtmpfs does not provide and a userspace init is expected to make itself:

LinkTarget
/dev/fd/proc/self/fd
/dev/stdin/proc/self/fd/0
/dev/stdout/proc/self/fd/1
/dev/stderr/proc/self/fd/2

Without these, bash process substitution (< <(...)) and any /dev/stdin-style redirect fail — a gap that surfaces in ordinary shell scripts long before it surfaces anywhere in the init itself.

schema-init also creates /run/log/schema-init/ at boot. Each service's stdout and stderr are redirected there automatically (see Logs).

If your system needs additional mounts (data partitions, network filesystems), run them as oneshot services before your other services depend on them.

Mount by UUID= or LABEL=, never /dev/sdX. The kernel assigns sda/sdb/… in detection order, which can change between boots — so a oneshot that mounts /dev/sdb1 may silently land on the wrong physical disk, swapping two data drives and pointing every absolute path at the wrong filesystem. /etc/fstab under systemd hid this by mounting by UUID for you; schema-init doesn't read fstab, so do it explicitly: mount UUID=1b7d654f-… /mnt/data (or LABEL=). The same applies to the root LABEL=/PARTUUID= on the kernel cmdline.


Building

make

Produces a fully static binary — no glibc version dependency, runs on any Linux kernel. Tested on:

  • Debian Bookworm, kernel 6.1, x86_64 — headless and Cinnamon desktop
  • Fedora 44, kernel 7.0, x86_64 — full KDE Plasma desktop, btrfs subvolume boot

Cross-compile for aarch64 (ARM — Ungulate Leg target):

make aarch64

Requires aarch64-linux-gnu-gcc. On Fedora: sudo dnf install gcc-aarch64-linux-gnu. Produces static schema-init-static, schema-ctl, and schema-subreaper binaries. Override sysroot with SYSROOT=/path/to/sysroot make aarch64.

ARM bare-metal (Pi Zero W, armv6l):

Fedora's arm-linux-gnu-gcc cross-compiler does not ship an arm sysroot. Compile natively on the Pi:

sudo apt install git gcc make
git clone https://github.com/ajax80/schema-init
cd schema-init && make

The armhf Makefile target exists for environments that have a full arm sysroot available.

schema-desktop (optional SDL2 monitor):

make desktop
sudo cp desktop/schema-desktop /usr/local/bin/schema-desktop

Requires SDL2 and SDL2_ttf. On Fedora: sudo dnf install SDL2-devel SDL2_ttf-devel. Reads live service state from PID 1's shared memory segment — run it from the desktop after login, or drop distros/*/config/autostart/schema-desktop.desktop into ~/.config/autostart/ to launch it automatically.

# install as PID 1 — symlink approach (distro-compatible)
cp schema-init /sbin/schema-init
ln -sf /sbin/schema-init /sbin/init

# or pass to kernel directly via GRUB
linux /boot/vmlinuz root=LABEL=my-root init=/sbin/schema-init

GRUB setup

Option A — symlink (/sbin/init → /sbin/schema-init): works with any distro GRUB config, no kernel cmdline change needed. Replace your distro's init binary or point the symlink.

Option B — explicit init= in GRUB: add init=/sbin/schema-init to the kernel line in /etc/default/grub, then grub-mkconfig -o /boot/grub/grub.cfg (Debian/Ubuntu) or grub2-mkconfig -o /boot/grub2/grub.cfg (Fedora).

Option C — custom GRUB menu entry: create a separate entry that leaves the distro default untouched:

# /boot/grub/custom.cfg  (included automatically by grub.cfg)
menuentry 'schema-init' {
    search --no-floppy --label --set=root schema-root
    linux   /boot/vmlinuz-$(uname -r) root=LABEL=schema-root rw quiet init=/sbin/schema-init
    initrd  /boot/initramfs-$(uname -r).img
}

Option C is the safest for dual-boot or first-time installs — it leaves the existing systemd entry intact as a fallback.

No initramfs? The root spec must be kernel-native. schema-init happily boots with no initramfs (the kernel hands straight to PID 1), but then nothing resolves a filesystem-level root spec for you — root=UUID=… and root=LABEL=… are resolved by dracut/udev from inside the initramfs, which no longer runs. A no-initramfs kernel can only resolve a kernel-native device: root=PARTUUID=… (GPT partition UUID, from blkid -s PARTUUID -o value /dev/…), root=PARTLABEL=…, or root=/dev/…. If you drop the initrd line above, you must also switch root= to one of these, or the kernel panics before PID 1 with VFS: Unable to mount root fs on unknown-block(0,0) — schema-init never gets to run. Keep rootflags=subvol=root (or your subvol) for a btrfs root. The fs-UUID and the PARTUUID are different values; don't paste one where the other belongs.

Kernel cmdline words are safe. The kernel hands PID 1 every boot-cmdline token it didn't consume (rhgb, quiet, splash, plymouth.debug, …), so leave your usual options in the kernel line — schema-init ignores them when it runs as PID 1. A services directory other than the default /etc/schema-init/services can only be set by hand-running the binary (schema-init /path/to/services), never via the kernel cmdline.

Replacing a running init (without reboot)

PID 1 can swap itself onto a new binary in place. Services keep running with the same pids, restart counts, timers and readiness state, and the desktop session never notices:

sudo schema-ctl reexec            # re-exec into the binary PID 1 booted from (now the new one on disk)
sudo schema-ctl reexec /path/bin  # or into a specific absolute path
# ok: re-executed into 0.4.0-1.990…, 60 services adopted, 0 new

With the RPM, you don't need to run it. On an upgrade, the package re-executes PID 1 itself once the transaction finishes, so dnf upgrade is the whole update. It is skipped in a chroot or container (the installer, mock) and when PID 1 isn't schema-init. A refusal is printed and never fails the transaction.

How it stays safe:

  • Dry run first. The new binary is started as a child with --reexec-check and must accept the state before PID 1 commits. A binary that crashes, is the wrong program, or can't read the state is refused, and the running PID 1 is untouched.
  • State is handed over, not re-derived. The runtime half of every service is serialized as versioned text into a sealed memfd. The control, notify and watchdog fds are inherited. The new image skips boot-only work (mounts, module loading, /tmp lock cleanup, watchdog arming) and adopts the running children.
  • Fallback. If the new image fails after the swap, it re-executes the old one (held open by fd) and the command replies err: rolled back.
  • Refused when a .svc changed since boot (those changes need a reboot), when the system is under memory pressure, or for a relative path. schema-ctl exits 1 on any refusal.

Measured on blakbox (60 services, full Plasma session): 53 ms per re-exec, schema-ctl status byte-identical before and after.

One caveat: a PID 1 older than re-exec support (COPR before 0.4.0-1.983, git before v0.4.0-31) has no reexec verb, so moving off it takes one last reboot.

If you install the binary by hand, it can't be overwritten while it runs (text file busy). Copy it next to the old one and mv it over, which replaces the directory entry atomically, then re-exec:

cp schema-init /usr/bin/schema-init.new
mv /usr/bin/schema-init.new /usr/bin/schema-init
sudo schema-ctl reexec

Real numbers

Tested on Dell Inspiron 3542 (Intel Core i3, 4GB RAM) running full Cinnamon desktop:

Metricschema-initsystemd (same hardware, Fedora)
PID 1 RSSsee PID 1 RSS — every measurement(not measured on this machine)
PID 1 threads120–30+
RAM used at desktop~1.1 GB~1.6–2.0 GB
Swap used0 MB200–500 MB
Time to desktop~20.7sslower

The gap is structural. schema-init spawns your services and then sits in a 250ms tick loop. There is no journal daemon, no dbus-broker, no socket activation layer, no unit file parser running in the background.

Boot timing breakdown (Dell Inspiron 3542, Debian Bookworm, kernel 6.1.0-49, times relative to PID1 start):

kernel → PID 1:    6.968s
dbus               1.761s   (ready: /run/dbus/system_bus_socket)
elogind            2.739s   (ready: /run/systemd/seats)
polkitd            3.447s
udev               3.197s
network           10.505s   (oneshot)
network-manager   11.757s
getty-tty1        10.755s
sshd              10.755s
display-manager   13.760s   ← LightDM login screen visible

total kernel → login screen: ~20.7s

schema-ctl timing produces this output.

Fedora 44 KDE, installed from the ISO: systemd vs schema-init

Measured 2026-09-25 on an HP 15-bs2xx (Celeron N4000, 4 GB RAM, 5400 rpm HDD): a fresh v0.3.1 ISO install with the schema-udev flip done, fully dnf upgraded, kernel 7.2.7 on every run. Both sides autologin into the same Plasma 6 session. Each run is a warm reboot into the named GRUB entry (grub2-reboot); readings are taken 4 minutes after boot by tests/livetest/boot-metrics. Times are seconds from kernel start to the process being spawned, read from /proc/<pid>/stat, so firmware and bootloader (~16.5 s here, identical for both) are not included. Medians of 3 runs; raw data in tests/livetest/dbox-bench-20260925.txt.

systemd (stock Fedora)schema-init (as installed)schema-init + host tuning+ schema-dbus
kwin_wayland spawned45.3 s45.5 s45.6 s38.4 s
plasmashell spawned64.3 s59.1 s58.9 s53.6 s
desktop drawn (desktop.so kioworker)92.2 s87.8 s84.0 s76.9 s
xdg-desktop-portal-kde spawned72.9 s60.1 s58.9 s53.6 s
RAM used at idle1477 MB1104 MB1036 MB1058 MB
processes219165163171
load average (1 min) at 4 min1.400.290.240.28
IO pressure (some avg300)11.48.57.77.7

With the stock D-Bus daemon, both inits reach the compositor at the same moment, because on a 5400 rpm disk that part is bound by reading files. schema-init's own gain comes after that: the shell and portal are up sooner, and the booted machine carries 373 MB less RAM and about 54 fewer processes. "Host tuning" is per-machine configuration, not part of schema-init: zswap off (zram only), noatime, Wi-Fi power save off, file indexer and unused autostart entries disabled, KWin blur/contrast off. It does not move the shell start, but it draws the desktop about 4 s sooner and trims another ~70 MB.

The last column (measured 2026-09-27, medians of 4 runs) adds schema's own message bus, schema-dbus, on both the system and session bus. To isolate it, the same box with the same tuning was measured with the switch rolled back to the stock daemon (3 runs: kwin 46.0 s, plasmashell 61.1 s, desktop 83.5 s, 1050 MB — matching the 09-25 column), then switched back. schema-dbus alone brings the compositor up 7.6 s sooner and draws the desktop 6.6 s sooner, at the same RAM; the gain lands before the compositor starts. Raw data in tests/livetest/dbox-bench-20260927.txt. End to end, the desktop is drawn about 15 s sooner than on stock systemd.

These are one laptop's numbers. The run-to-run spread was small (plasmashell 57.7–59.7 s on schema-init vs 63.5–64.4 s on systemd), but a different disk, CPU or desktop will give different figures.

PID 1 RSS — every measurement

One table, every number, each naming the machine, the build and the service count it came from. Anything not listed here is not a measurement we have.

PID 1RSSMachine / conditionsMeasured
schema-init1.2 MBminimal static boot, QEMU/KVM 512 MB / 2 vCPU2026-06-14
schema-init2.6 MBlive desktop, QEMU/KVM 512 MB / 2 vCPU2026-06-14
schema-init3.3–4.0 MBFedora 44, KDE Plasma + Docker/podman, 47 services, v0.1.0 — six consecutive boots2026-07-16 → 07-24
systemd20.1 MBFedora Cloud Base 44 clean idle, 15 running units, same kernel, same QEMU profile2026-06-14

On the same kernel and QEMU profile that is 8–17× lighter. The desktop and the Cloud Base figures are not a fair pair — one runs KDE, the other is headless — so they are not presented as one. The only apples-to-apples comparison here is minimal-boot schema-init vs clean-idle systemd.

Two numbers this README used to carry, and why they're gone:

  • "892 KB" was real, but it was an earlier and smaller build on the Dell. Current builds measure 1.2 MB minimal and 3.3–4.0 MB at a full desktop; the init has grown (timers, cpuset, cgroup delegation, container support). Leading with the lowest figure ever recorded, from a binary you can no longer download, isn't a footprint claim — it's cherry-picking. 892 KB was also never the binary's size on disk (see Binary size).
  • "40 MB – 120 MB" for systemd's PID 1 was never measured by this project. The measured figure is 20.1 MB, above.

Binary size

make produces an unstripped static binary. Measured on the v0.1.0 build (601b18ac, 2026-07-24):

Bytes
as built (static, with debug info)5,607,8405.6 MB
after strip schema-init1,199,1441.2 MB
.text alone1,119,0861.1 MB

.text alone is 1.1 MB, so no build of this binary has ever been under 1 MB on disk. Reproduce with ls -l, size and strip. Note that plain make does not strip — quote 5.6 MB for what you build yourself, 1.2 MB only for a stripped binary. make release produces the stripped set in release/ alongside a SHA256SUMS file; that is what release assets ship.

Architectural efficiency

Live measurements from a 9-hour uptime session (Fedora 44, KDE Plasma, GreyBox — note this node runs an older, smaller build; its RSS is not comparable to the current one):

Metricsystemdschema-initArchitectural elimination
Idle CPU consumptionConstant ambient timer wakeups~0.03ms/min (1.06s over 9h)CPU reaches deeper C-states — hardware idle, not just low-utilization idle
State trackingD-Bus event loops, logging daemonsDirect POSIX shared memory / binary flag probesRemoves IPC serialization and deserialization bottlenecks entirely
Session trackingutmp/logind infrastructureGhost sessions — who/w show 0 usersZero inode contention on /var/run/utmp; who and w are zero-overhead no-ops under concurrent logins

The load average on an idle system with schema-init as PID 1 sits at 0.03. On the same hardware with systemd, ambient timer wakeups hold it at 0.10–0.20 at idle. The difference is structural: schema-init's tick loop sleeps indefinitely once all services are stable. Nothing wakes it.

turbostat on Eli (Dell Inspiron 3542, Intel i3-4005U, Fedora 44, full Cinnamon desktop):

C10%: 92–99%    ← deepest available C-state; CPU hardware-verified
C6%:  0.00%     ← skipped; CPU goes straight to C10
Busy: 0.21–0.38%
PkgWatt: 1.23–1.32W   ← entire SoC including iGPU, read via Intel RAPL
GFX%rc6: 99.67%        ← integrated GPU in deepest sleep state

C10 is the deepest sleep state on Haswell silicon. Reaching it requires the CPU to sit undisturbed long enough to flush caches and power-gate internal voltage rails — typically blocked by the constant timer wakeups from systemd's watchdog, journal flush, and D-Bus polling infrastructure. At 92–99% C10 residency with a full desktop running, schema-init is generating near-zero ambient noise. The 1.25W package figure is read directly from Intel RAPL hardware energy counters, not estimated. Services with ready_path set promote the instant the path exists — no blind timer. stable_secs (default 10s) is the fallback. The remaining ~10s cluster is network/getty/sshd with no readiness path.


Runtime control

schema-ctl is a control client that communicates with the running init over a Unix domain socket at /run/schema-init.sock.

sudo schema-ctl status          # full state dump for all services
sudo schema-ctl status --json   # machine-parseable JSON — for supervisory loops and IEC 62304 audit
sudo schema-ctl status --kv     # flat key=value — grep-friendly
sudo schema-ctl status <name>   # one service: state, last exit, readiness, each hardening knob and its source
sudo schema-ctl timing          # per-service spawn→ready cost, boot critical path first
sudo schema-ctl analyze         # boot critical chain + waterfall; how each service proved ready (notify / bus-name / ready_path / stable timer / exit)
sudo schema-ctl analyze <name>  # the dependency chain that held <name> back
sudo schema-ctl list            # names and current states only
sudo schema-ctl start <name>    # start a stopped or EXCISED service
sudo schema-ctl stop <name>     # send SIGTERM to a running service
sudo schema-ctl restart <name>  # stop + re-queue through the state machine
sudo schema-ctl add <path>      # load a new .svc file at runtime, no reboot needed
sudo schema-ctl reload          # re-read the services directory (rejected if new config has a cycle)
sudo schema-ctl reload --evict  # reload + SIGTERM any running service no longer present in config
sudo schema-ctl pet <name>      # service heartbeat check-in — resets watchdog_timeout_ms window
sudo schema-ctl reset [<name>]  # reset restart/dormant counts and re-queue failed services
sudo schema-ctl reexec [<path>] # replace the PID 1 binary in place, services keep running
sudo schema-ctl reboot          # orderly shutdown sweep, then reboot (also: poweroff)

schema-ctl exits 1 when PID 1 refuses a command (err: reply), so scripts can check it. The socket is chmod 0600 — root only. Build alongside the init binary:

make schema-ctl
sudo cp schema-ctl /usr/local/bin/schema-ctl

Recovery console

When a Wayland compositor wedges, ctrl-alt-F2 only gets you another login on the same broken session, and systemd's rescue/emergency targets are all-or-nothing — they tear the session down and lose your work. schema-board on a dedicated VT is the alternative: a surface that sits below the compositor and shows you what is actually wrong.

schema-board --tty /dev/tty8                    # then ctrl-alt-F8 to look at it
schema-board --tty /dev/tty8 --interactive      # ...and fix something from there

--interactive adds a cockpit: ↑/↓ (or j/k) to select a service, enter to raise a card, y to apply, n to cancel. The card is chosen from the service's state — DORMANT gets reset, EXCISED gets start, anything else gets restart — and the confirm panel prints the exact command before it runs:

 ▸ restart frigate?
   will run: schema-ctl restart frigate
   [y] apply   [n] cancel

Browsing stays read-only. The board is a pure shared-memory reader until you press y, so it needs no root to watch and keeps working when the control socket is wedged. Only applying a card opens the socket, and that needs root. The board can only ever issue a command you could have typed yourself. ctrl-C always works — ISIG is left on deliberately.

Note that this lets anyone at the physical console restart a service. That is not a new privilege boundary — the shipped gettys autologin root on tty2 — but it is worth knowing before you enable it on a machine other people can walk up to.

It reads the shared-memory export rather than the control socket and depends on nothing graphical, so a frozen desktop, a wedged control socket, and a saturated D-Bus all leave the process working. Give it a VT no getty owns — services/ ships gettys on tty2–tty6, and tty1 is the display manager, so tty7 and up are free.

What it survives, and what it does not

✅ VT switching works on a graphical schema-init system. This was broken until 2026-07-26 and is now fixed in scripts/schema-logind.py. Confirmed on real hardware — NVIDIA, sddm-started KDE Wayland session — by a human looking at the screen, which is the only evidence that settles a question about what is visible.

The board keeps reading and updating while the compositor is wedgedYes — seq advanced 228430 → 228569 across a 30 s freeze with kwin_wayland in state T
You can see it while the compositor is healthyYes — ctrl-alt-F8 shows the console, ctrl-alt-F1 returns to a repainted desktop
You can see it while the compositor is wedgedYes — the board rendered, in colour, with kwin_wayland in state T

The wedged case is what the recovery console exists for, and it is the fail-safe that carries it. With the compositor SIGSTOPed it cannot answer PauseDeviceComplete, so every device ack goes missing. Releasing the VT anyway — rather than waiting for acks that will never arrive — is the only reason the switch completes:

VT release requested — pausing 10 device(s)
DROP_MASTER ok on fd=11
10 device ack(s) missing — releasing anyway
VT_RELDISP(1) — switch allowed to proceed

Blocking there would strand the kernel mid-switch with DRM master already dropped: a black screen with no way back. On SIGCONT the compositor flushes all ten acks and a late Seat.SwitchTo for the keypress it was frozen on, and the session recovers. Note that in the wedged case the kernel drives the handoff alone through VT_PROCESS — Seat.SwitchTo arrives after the release, not before it as in the healthy case. That is the difference a polling implementation cannot cover.

How the handoff works

A graphical session needs this chain, and every link now exists:

ctrl-alt-F<n>
  → kernel signals schema-logind (VT_PROCESS) and WAITS   ✅ VT_SETMODE at TakeControl
  → logind sends PauseDevice to the compositor            ✅ pause, per device taken
  → logind drops DRM master                               ✅ DRM_IOCTL_DROP_MASTER
  → logind acks with VT_RELDISP, kernel completes switch   ✅
  → fbcon restores the mode with master already free      ✅ console repaints
  → on return: VT_RELDISP(VT_ACKACQ), SET_MASTER, ResumeDevice

VT_PROCESS mediation is the load-bearing part, and a polling implementation cannot replace it. An earlier fix watched /sys/class/tty/tty0/active every 250 ms and implemented PauseDevice/ResumeDevice, VTNr, and Seat.SwitchTo — all necessary, none sufficient. The kernel completes a VT switch synchronously, and fbcon's mode restore runs during it, while master is still held; it fails silently and is never retried. Measured: after the poll dropped master the console sat at 15 non-black pixels, and a second switch — master already free — painted 32,771. The active-VT poll survives only as a fallback for when VT_SETMODE fails.

Two things this depends on, both worth knowing before you touch it:

  • ReleaseControl restores VT_AUTO. KWin calls it from ~LogindSession. Anything that takes control and exits — a display-manager greeter, for instance — tears mediation down for whoever comes next.
  • The chord arrives twice, once from the kernel's VT handler and once from the compositor calling Seat.SwitchTo for the same keypress. Handling it twice overwrites and leaks the pending-ack timer.

Two earlier revisions of this section were wrong in ways worth recording. The first blamed a stopped compositor for being unable to release DRM master; a healthy one does not release it either, because nothing asks. The second proposed having the board take DRM master itself, which cannot work — DRM_IOCTL_SET_MASTER fails while another process holds master. The real cause was that Properties.Get for VTNr failed, so KWin's LogindSession::create() bailed and it silently fell back to NoopSession, whose switchTo() is an empty function body.

On a machine with no graphical session — a server, a Pi, an initramfs-less boot before the display manager starts — the recovery console works regardless, because nothing has taken KMS.

If you are stranded on an invisible VT, sudo chvt 1 from any other shell (ssh included) puts you back.

To have PID 1 own it from boot, copy services/schema-board.svc.example into /etc/schema-init/services/:

name=schema-board
exec=/usr/bin/schema-board
args=--tty
args=/dev/tty8
needs_root=1
critical=0

Two things about that file are load-bearing:

  • args= is one argument per line. args=--tty /dev/tty8 on a single line passes one argv of "--tty /dev/tty8", which schema-board rejects. Repeat the key.
  • --tty is not optional for a service. Services are spawned with stdout redirected to /var/log/schema-init/<name>.log, so without --tty the board would faithfully paint its frames into a logfile.

On the console it takes over, the board disables screen blanking and hides the cursor, restoring the cursor when it exits.


Debugging

Service state

sudo schema-ctl status          # full dump: state, pid, restart count, weight
sudo schema-ctl list            # compact: name + state only
sudo schema-ctl timing          # kernel→PID1 handoff + per-service stable timestamps

A service stuck in NEW_PROCESS means its dependencies haven't stabilised. status shows the state of every dep — trace upward.

A service in FRICTION is in last-chance recovery. On the next failed F6 probe it enters DORMANT (exponential backoff) rather than going straight to EXCISED. Use sudo schema-ctl start <name> to manually re-queue it immediately instead of waiting out the backoff.

Service logs

tail -f /run/log/schema-init/<name>.log    # live stdout/stderr for a service
cat /run/log/schema-init/dbus.log          # full output since last boot

These are plain text on a tmpfs. If a service is failing silently, its output is here.

D-Bus tracing

If a desktop application hangs for exactly 25–30 seconds, D-Bus auto-activation is timing out trying to reach an unregistered interface. Trace it:

dbus-monitor --system 2>&1 | grep -A4 "method call"

The culprit will appear as a method call to a destination=org.freedesktop.SomeName that produces no method return for ~25 seconds.

Fix options:

  1. Register the interface — see schema-logind for the pattern
  2. Mask the activation file: sudo rm /usr/share/dbus-1/system-services/<name>.service

Rescue shell

If schema-init drops to a rescue shell at boot (cycle detected, or fatal probe failure), you have a minimal /bin/sh with access to the mounted filesystems. From there:

# inspect service files
ls /etc/schema-init/services/
cat /etc/schema-init/services/broken.svc

# fix and re-exec
vi /etc/schema-init/services/broken.svc
exec /sbin/schema-init

Crashes

Fedora's initrd points kernel.core_pattern at systemd-coredump, which hands each core to a socket-activated systemd-coredump.socket that does not exist under schema-init, so every crash is logged as "Failed to connect to coredump service" and the core is thrown away. At boot PID 1 replaces that pattern, or the kernel's default core when the initrd sets none, with schema-coredump (apport, abrt or a pattern you set by hand is left alone), then raises its soft RLIMIT_CORE to the hard limit so services inherit a limit the helper can honour.

Each crash gets a record in /var/lib/schema-coredump/ (root-only, 0700): core.<comm>.<uid>.<time>.<pid>.meta with the pid, signal, exe, command line, cgroup and schema-init service, and beside it a zstd core unless something says not to:

RESULTmeaning
storedcore kept
rlimitthe process ran with RLIMIT_CORE below 4 KiB; it opted out
rate-limitedthe same program already left a core in the last 60 s (crash loop)
no-spacethe filesystem is within 10% of full
truncatedthe core hit a cap: RLIMIT_CORE, 2 GiB uncompressed, or the space budget

Old cores are removed oldest-first once they total 4 GiB; 200 records are kept. One line per crash goes to syslog (/var/log/schema-init/journal.log).

sudo schema-coredump --list
sudo zstd -dc /var/lib/schema-coredump/core.foo.….zst > core && gdb /usr/bin/foo core

If something re-applies sysctl.d after boot (a sysctl --system service), it puts systemd-coredump back; run schema-coredump --take-pattern after it.


Logs

Init log — schema-init writes spawn/promote/death events to stdout, which the kernel connects to the console at boot. To persist:

exec /sbin/schema-init >/var/log/schema-init.log 2>&1

Per-service logs — each service's stdout and stderr are captured automatically to:

/var/log/schema-init/<name>.log        # preferred (persists across boots)
/run/log/schema-init/<name>.log        # fallback when /var is not writable (tmpfs, per-boot)

To read them while the system is running:

tail -f /var/log/schema-init/dbus.log
tail -f /var/log/schema-init/network-manager.log

There is no journal daemon. Logs are plain text, always.

Rotation — schema-init.logrotate is installed to /etc/logrotate.d/schema-init (daily, or sooner at 100 MB, keeping 4 compressed generations). It covers /var/log/schema-init/*.log and the KDE deploy's /var/log/sddm-schema.log, which sddm-logged writes under set -x. It uses copytruncate, and that is not optional:

  • A service's log fd is opened in the child before exec (service.c:321) and held for the whole life of the process. Renaming the file would leave every running service appending to the old inode — the new file would stay empty until the service restarted.
  • SIGHUP to PID 1 means reload configuration (init.c:1312), not "reopen logs". A postrotate kill -HUP 1 would silently trigger a config reload instead of rotating.
  • Every writer uses O_APPEND (service.c:321, schema-journal-sink.c:344), which is what makes truncation safe: writes resume at offset 0 instead of leaving a sparse hole at the old offset.

Something has to run logrotate. There is no cron and no logrotate.timer here, so schedule it as an ordinary wall-clock timer — see services/logrotate.svc.example:

name=logrotate
exec=/usr/sbin/logrotate
args=/etc/logrotate.conf
needs_root=1
on_calendar=00:10
persistent=1

Copy it into your service directory to arm it:

sudo cp /usr/share/schema-init/services/logrotate.svc.example \
        /etc/schema-init/services/logrotate.svc
sudo schema-ctl reload

persistent=1 matters here: a box that is powered off at 00:10 would otherwise skip that day's rotation entirely and only catch up the next night.

Without that timer (or some other caller) the config sits inert and logs still grow unbounded. maxsize 100M is not a safety net on its own — it is only consulted when logrotate actually runs.

journalctl shim (optional Track B) — software and post-install scripts that shell out to journalctl -u <svc> would fail with no journald present. scripts/journalctl is a drop-in interceptor: install it to /usr/local/bin/journalctl and it serves the matching *.log from the directories above, swallows unknown flags, supports -o json, and always exits 0 so a caller piping it to jq/awk never hard-crashes. It does not read a binary journal — there isn't one.


Shared memory interface

Running processes can read service state via POSIX shared memory at /schema-init:

#include "schema_shm.h"

int fd = shm_open("/schema-init", O_RDONLY, 0);
schema_shm_t *shm = mmap(NULL, sizeof(schema_shm_t), PROT_READ, MAP_SHARED, fd, 0);

for (int i = 0; i < shm->count; i++) {
    printf("%s state=%d weight=%d pid=%d\n",
           shm->svc[i].name,
           shm->svc[i].state,
           shm->svc[i].weight,
           shm->svc[i].child_pid);
}

D-Bus compatibility

On a no-systemd desktop, several interfaces are missing that desktop environments expect. schema-logind (distros/*/services/schema-logind.svc) handles the session/power/host interfaces in a single Python process on the system bus. The org.freedesktop.systemd1 management surface is served by its own process — see below.

InterfaceWhy it mattersWhat schema-logind returns
org.freedesktop.login1Power/reboot buttons, session tracking, polkit seat queriesPowerOff, Reboot, CanPowerOff, CanReboot, Inhibit, GetSessionByPID, mock Session/User/Seat objects
org.freedesktop.ConsoleKitCinnamon session manager uses ConsoleKit, not logind, for CanRestart/CanStop — controls restart button visibilityGetSessionForUnixProcess, CanRestart → True, CanStop → True, Restart/Stop → SIGINT/SIGTERM to PID 1
org.freedesktop.hostname1About This System panel, network-manager displayhostname, static hostname, OS pretty name, hardware vendor/model from /sys/class/dmi/
org.freedesktop.systemd1systemctl, Cockpit's Services page, and KDE/GNOME unit-state queries — the full systemd-compat management surfaceLive per-unit ActiveState/SubState/MainPID/NRestarts mapped from schema-ctl; ListUnits/ListUnitsFiltered, GetUnit, GetUnitFileState, StartUnit/StopUnit/RestartUnit driving schema-ctl for real; PropertiesChanged on state transitions. Served by a separate schema-systemd1 process (see below)
org.freedesktop.timedate1Date & Time settings panel: timezone, NTP status, clockTimezone (from /etc/localtime), CanNTP/NTP/NTPSynchronized → true, TimeUSec; SetTimezone re-links /etc/localtime and writes /etc/timezone for real

Without these stubs, KDE and GNOME panels hit the D-Bus default timeout (25–30s) before giving up. With them, the same queries return in <100ms.

D-Bus policy required. The systemd-shipped org.freedesktop.login1.conf policy denies all non-root calls to login1 by default — KDE and Cinnamon will never see the power buttons without a drop-in. Install the one from this repo:

sudo cp distros/shared/dbus/schema-logind.conf /etc/dbus-1/system.d/schema-logind.conf
sudo dbus-send --system --type=method_call --dest=org.freedesktop.DBus \
    /org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig

Then log out and back in (or reboot). The policy whitelists CanPowerOff, CanReboot, PowerOff, Reboot, and all session/seat methods schema-logind exports.

schema-logind is not a dependency of schema-init itself — it is a userspace service like any other. Drop its .svc file in your services directory and list it as a dep of your display manager:

name=sddm
exec=/usr/sbin/sddm
dep=dbus
dep=schema-logind
dep=polkitd
needs_root=1

The real systemd1 surface (schema-systemd1). Unlike the read-only stubs above, org.freedesktop.systemd1 is served by its own process (services/schema-systemd1.svc → scripts/schema-systemd1.py), not schema-logind. It registers every schema-init unit as a …/unit/<name>_2eservice object and mirrors live state from schema-ctl, so systemctl status <svc> and Cockpit's Services page show real ActiveState/SubState/MainPID/restart counts — and StartUnit/StopUnit/RestartUnit drive schema-ctl for real. Unit names are validated before being handed to schema-ctl (argv/newline injection guard). State transitions emit PropertiesChanged, so Cockpit updates without a refresh. Design notes: docs/superpowers/specs/2026-06-20-schema-systemd1-dbus-design.md.

The sd_booted() signal. mount_pseudo() creates /run/systemd/system at early boot (init.c). libsystemd's sd_booted() is a bare access() on that path, so any software gated on "is systemd the init?" — KService/ksycoca, elogind clients — gets a positive answer with no shim. This is what made the old LD_PRELOAD mock_sd.so workaround (which faked the check to stop KDE's ksycoca from spinning at idle) unnecessary: the signal is now native and costs one mkdir.

The sd_login_monitor directories. libsystemd's sd_login_monitor_new(NULL, …) — used by WirePlumber's logind module and other session/seat-aware clients — sets an inotify watch on /run/systemd/{sessions,seats,users,machines}. If any of those directories is missing the call fails with -ENOENT and the client silently drops logind integration (for WirePlumber that means no device reservation, no session-based pause). schema-logind creates all four at startup, empty — matching what real logind does even with no active sessions — so those clients initialize cleanly. Costs four mkdirs.

The system bus itself (schema-dbus)

Everything above runs on the D-Bus system bus. schema-dbus (schema-dbus.c) is that bus — a native C implementation of the message broker that replaces dbus-daemon/dbus-broker as the process owning /run/dbus/system_bus_socket. It does the real work of a bus daemon: EXTERNAL authentication, well-known and unique name ownership, client→client routing with reply tracking, match-rule signal delivery, the org.freedesktop.DBus driver, and unix-fd passing — libdbus can't demarshal fd-carrying messages, so the broker parses the wire format directly and forwards the fds opaquely (sdbus_wire.h).

It enforces the same policy as the stock daemon. The system's shipped busconfig XML (/usr/share/dbus-1/system.conf and its system.d/ drop-ins) is dissolved into a flat allow/deny table at startup by scripts/schema-dbus-run.sh, which then execs the broker against it — so <deny>/<allow> rules for login1, polkit, NetworkManager and the rest are honored exactly. A conformance test checks the dissolved verdicts against a frozen corpus (tests/test_sdbus_conformance.c).

Activate it the way you'd flip udev — advanced, opt-in, reversible:

sudo make install-dbus-sp1                                    # installs broker + launcher, changes nothing yet
cp services/dbus.svc.sp1 /etc/schema-init/services/dbus.svc   # the flip
sudo reboot

The installer ISO and the migration wizard both offer this flip as an optional step after the udev cutover, in its own reboot, with a seatbelt health check that rolls back to stock dbus-daemon automatically if the bus doesn't come up (shipped since v0.4.1, hardware-tested on a real install). The manual steps above are for source builds.

Build needs dbus-devel (make schema-dbus). If the policy dissolve ever fails at boot, the launcher self-heals to stock dbus-daemon on the spot, so even a broken flip still comes up on a working bus; to roll back permanently, restore the stock dbus.svc (exec=/usr/bin/dbus-daemon, args=--system, args=--nofork) and reboot.

Status. Proven serving a full KDE Plasma desktop as the live system bus across reboots — kwin, plasmashell, polkit, PowerDevil, portals, WirePlumber, tailscale and schema-logind all routing through it. The driver interface is complete: on-demand service activation (implicit and StartServiceByName), ReloadConfig, UpdateActivationEnvironment (session bus), ListQueuedOwners, introspection and the driver properties, and BecomeMonitor, so busctl monitor, dbus-monitor, busctl status and gdbus introspect all work against it. Name ownership honors the policy's own rules exactly like dbus-daemon. Validate in schema-vmtest before flipping hardware.

The session bus, too. The same broker also runs the per-user session bus ($XDG_RUNTIME_DIR/bus). scripts/schema-dbus-session-run.sh is a drop-in for plasma-dbus-run-session-if-needed: it starts schema-dbus without --system (which is what selects session mode, with session.conf's allow-all policy), exports DBUS_SESSION_BUS_ADDRESS, and runs the session command as its child. If the broker won't come up, it falls back to stock dbus-daemon --session. A watchdog tears the broker down when the session exits, and ends the session if the broker dies mid-session, so the autologin loop respawns it cleanly instead of leaving a desktop with no bus. The fedora-kde autologin uses it by default. Proven live on a KDE Plasma desktop — kwin, plasmashell, Dolphin, Konsole, portals, the tray.


Running packaged software

The D-Bus surface above lets tools manage schema-init units as though systemd were running. The other half of the compatibility story is install-time: an RPM or deb scriptlet that runs systemctl enable foo or systemctl daemon-reload must not error out on a schema-init box, and the foo.service it just dropped into /usr/lib/systemd/system has to become something schema-init can actually run. Two pieces cover this — a translator, not an emulator.

The shim (schema-systemctl). A systemctl(1) drop-in that packaging swaps in for /usr/bin/systemctl (via alternatives + a symlink). It honours systemd's verb and exit-code contract so scriptlets succeed: lifecycle verbs (start/stop/restart/status/is-active/is-enabled) drive schema-ctl for real, enable/preset record enable-intent to /var/lib/schema-init/pending.list (deduplicated) — the hand-off to the importer — and daemon-reload and the other no-op-here verbs exit 0 so nothing a caller pipes into hard-crashes. All logic lives in systemctl_shim.h (header-carried, static-inline; schema-systemctl.c is a thin entry point). Packaging is idempotent and reversible: %post saves the stock binary as systemctl.real, %postun restores exactly that — and on a box that never had systemd, removes the now-dangling shim symlink instead. Verified end-to-end through a real dnf install/dnf remove round-trip in a fedora:44 container.

The importer (schema-import). distros/fedora-installer/migrate/schema-import.py drains pending.list and translates each queued .service into a native .svc on 80/20 field coverage: ExecStart → exec=/args= (with $VAR/${VAR} resolved against the unit's own Environment=, since schema-init exec()s with no shell), Type=oneshot → oneshot=1, Type=notify/notify-reload → notify=1, Type=dbus + BusName= → ready_bus_name=, systemd's Restart=no default → no_restart=1, User= → user= (else needs_root=1), and Environment= → env= — a .svc key applied via setenv in the child before execv. Type=forking with an absolute PIDFile= → pid_file=. Known ratholes are logged and skipped, never half-translated: Type=forking without one, Type=dbus without BusName=, template units (foo@), and units with no usable ExecStart. Each skipped unit leaves <name>.svc.skipped beside the generated .svc files — the reason and the original unit, commented out — so it can be written by hand; PID 1 loads only *.svc, so the note is inert. A unit whose file isn't found is left queued; the drain is idempotent and rewrites the queue atomically (--dry-run/--force available). Design notes: docs/superpowers/specs/2026-09-14-schema-systemctl-shim-design.md.

Together this is the deployability turn: reclamation stops being "rewrite each daemon by hand" and becomes "install whatever ships, and import its units."


Porting to a new distro

Starting from scratch on a distro not in distros/:

1. Build the binary on the target (or cross-compile):

git clone https://github.com/ajax80/schema-init
cd schema-init && make

2. Install:

sudo cp schema-init /sbin/schema-init
sudo cp schema-ctl  /usr/local/bin/schema-ctl
sudo mkdir -p /etc/schema-init/services

3. Write service files. Start minimal — just enough to reach a console:

# /etc/schema-init/services/udevd.svc
name=udevd
exec=/usr/lib/systemd/udevd
args=--daemon
needs_root=1
stable_secs=3

# /etc/schema-init/services/dbus.svc
name=dbus
exec=/usr/bin/dbus-daemon
args=--system
args=--nofork
needs_root=1
stable_secs=2
ready_path=/run/dbus/system_bus_socket

The udevd path varies by distro: /usr/lib/systemd/udevd (Fedora/Debian), /lib/udev/udevd (older Debian), /usr/bin/udevd (Arch).

4. Configure GRUB (see Building → GRUB setup above). Boot with a fallback entry pointing at systemd so you can recover.

5. Boot and check:

sudo schema-ctl list       # all services should reach FUNDAMENTAL
sudo schema-ctl timing     # see where time goes
tail /run/log/schema-init/udevd.log   # if something is EXCISED, check its log

6. Add services incrementally. Bring up network, then login manager, then display manager. Add dep= links to enforce order. Add ready_path= for anything with a socket or pidfile.

7. Handle D-Bus hangs. Open your desktop's settings panel immediately after first login. If it hangs >5s, run dbus-monitor --system and identify the missing interface. Add a stub to schema-logind or mask the activation file.

Common issues by distro:

IssueCauseFix
udevd not populating /dev/inputudev not settled before display managerdep=udev in display manager svc; udevadm settle in a oneshot before it
polkit "not authorized" on NMpolkit rule missing wheel groupCopy distros/fedora-kde/config/polkit/10-schema-nm.rules
/etc/resolv.conf is a dead symlinksystemd-resolved wrote itrm /etc/resolv.conf && echo "nameserver 1.1.1.1" > /etc/resolv.conf in your network oneshot
Plasma/GNOME hangs on settings openMissing D-Bus interfaceSee D-Bus compatibility section above
PipeWire/PulseAudio not startingsystemd user session missingAdd autostart .desktop entry, or run from display manager wrapper script
display manager exits immediatelyNo seat availableEnsure elogind or schema-logind is up and answering login1 before display manager starts
X11/XWayland apps die with Unable to open display (Steam, any non-Wayland-native app)systemd-tmpfiles normally creates /tmp/.X11-unix as 1777 root:root; with no systemd it's missing or wrong-perm, and an X server refuses a /tmp/.X11-unix without the sticky bit — so the compositor's XWayland silently never starts and DISPLAY is never exportedoneshot before the display manager: mkdir -p /tmp/.X11-unix && chown root:root /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix (also clear stale /tmp/.X[0-9]*-lock). dep= it from the DM. On a root-fs /tmp (not tmpfs) the broken dir persists across reboots, so this isn't self-healing
flatpak/snap apps won't launch — The name org.<app>.desktop was not provided by any .service filesThe session D-Bus bus computes its .service search dirs once at startup from XDG_DATA_DIRS; with no systemd user env-generator that variable is unset when the bus is born, so it never scans …/flatpak/exports/share/dbus-1/services. Anything later (a plasma-workspace/env script) runs inside the bus's child — too lateExport XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share:/var/lib/snapd/desktop in the env that launches the session bus, before the bus starts. Stopgap without re-login: symlink the *.service files into ~/.local/share/dbus-1/services/ (always searched regardless of XDG_DATA_DIRS) and ReloadConfig the bus

Distributions

Working configurations for specific distros and desktops live in distros/.

Fedora 44 + KDE Plasma (distros/fedora-kde/)

Full KDE Plasma 6 desktop on Fedora 44 with schema-init as PID 1. Boots from a btrfs subvolume alongside a normal Fedora install — no repartitioning required.

What's running:

ServiceRole
udevdDevice enumeration — required for libinput and /dev/input/event*
dbusSystem bus
network-upLoads r8152 USB ethernet module, udev settle
network-managerOwns the network interface via NM profile
polkitdAuthorization — required for NM actions
schema-logindMinimal org.freedesktop.login1 D-Bus stub — restores KDE shutdown/restart buttons
sddmDisplay manager (via sddm-logged wrapper, no systemd session)
sound-modulesoneshot — loads AMD Ryzen audio modules at boot
bluetoothdStarts bluez daemon — registers org.bluez, restores KDE Bluetooth applet
zram-swaponeshot — zstd-compressed zram swap device; replaces systemd's zram-generator

See distros/fedora-kde/README.md for full installation instructions and key fixes.

Raspberry Pi Zero W (distros/raspberry-pi-zero-w/)

WiFi headless deploy on a Pi Zero W (BCM2835, armv6l, 32-bit ARM). No Ethernet, no HDMI — schema-init as PID 1, WiFi up, SSH accessible in ~50 seconds from cold boot. First ARM bare-metal target.

Service chain:

ServiceRole
udevDevice enumeration daemon
udev-triggerOneshot — coldplug trigger + settle; loads brcmfmac WiFi firmware
dbusSystem bus — mandatory for Pi OS wpa_supplicant
wpa-supplicantWiFi association (config-file mode, not D-Bus mode)
dhcpcdDHCP client, foreground (-B), wlan0 only
sshdFirst usable interface — up when DHCP lease is held

See distros/raspberry-pi-zero-w/README.md for the full list of gotchas (rfkill country code, dbus privilege drop, coldplug trigger, dhcpcd forking behavior) and installation steps.


Roadmap

  • Runtime service loading — schema-ctl add <path> loads a new service at runtime
  • Runtime reload + removal — schema-ctl reload [--evict] re-reads config (cycle-checked); --evict SIGTERMs services dropped from config, no reboot
  • login1 D-Bus stub — schema-logind restores KDE shutdown/restart buttons on no-systemd systems
  • event-driven main loop — signalfd for SIGCHLD + poll() with 250ms timeout; wakes on child death and ctl commands instead of busy-polling
  • Boot hang fix — dep_idx alignment bug in group dep resolution; poll() replaces epoll (PID 1 epoll deadlock on kernel 6.1.0-49)
  • Boot timing — schema-ctl timing reports kernel→PID1 handoff and per-service FUNDAMENTAL/PERFECT timestamps (CLOCK_MONOTONIC)
  • Boot time measurement — 29.5s → 20.7s with ready_path probes; stable_secs fallback per service
  • Per-service readiness probes — ready_path= promotes on path existence; stable_secs= fallback timer
  • Cgroup assignment race fix — pipe barrier guarantees cgroup.procs written before child exec
  • Dynamic poll timeout — loop sleeps indefinitely once all services stable; 0% CPU idle
  • Service log files — stdout/stderr per service at /run/log/schema-init/<name>.log
  • D-Bus stubs — hostname1 and systemd1 Manager stubs in schema-logind; KDE Settings 25s → 2s
  • Fedora KDE distribution — GreyBox daily driver, full KDE Plasma 6 on Fedora 44
  • Fedora Cinnamon distribution — Eli (Dell Inspiron), keyboard/touchpad/ethernet working
  • STATE_DORMANT (75) — exponential backoff before 76 verdict; critical services never excise
  • Soft dep cascades — non-critical EXCISED deps skipped; dependents proceed without them
  • aarch64 cross-compile — make aarch64; all three binaries static; Ungulate Leg target ready
  • ARM bare-metal deploy — Pi Zero W (armv6l), Pi OS Trixie; SSH up in ~50s from cold boot
  • schema-desktop — SDL2 live service viewer; make desktop + autostart entry in Cinnamon and KDE distros
  • Dead Man Token hardware watchdog — /dev/watchdog driven by per-service check-in via schema-ctl pet; any critical service missing its watchdog_timeout_ms window stops WDT petting → hardware reboot; PID 1 deadlock covered implicitly
  • Symlink template instances — motor@12.svc → motor@.svc; $INSTANCE injected at spawn; $SLOT_ID fallback for GPIO-strapped nodes; one SD card image per fleet
  • Structured telemetry — schema-ctl status --json and --kv for machine-parseable supervisory loop consumption and IEC 62304 audit traceability
  • Cgroup resource limits — cpu_limit= (1–100, % of one core), mem_limit= (MB), cpuset= (CPU affinity / core pinning, systemd AllowedCPUs= analog), and cpuset_partition= (isolated/root exclusive cores via cgroupv2 partitions — dynamic isolcpus) per .svc; written via sync-pipe window before child exec; IEC 62304 Class C blast-radius isolation
  • zram swap — zram-swap.svc boots a zstd-compressed zram swap device, replacing systemd's zram-generator; eliminates disk thrashing / periodic stutter under memory pressure
  • schema-udev — native .dev-rule device manager with an authoritative cutover that retires systemd-udevd and owns /dev: node creation, the /dev/disk/by-* + /dev/char + /dev/block symlink farm, uaccess seat ACLs, the libudev monitor broadcast, and the /run/udev/data database — checksum-backed, reversible, guarded by a headless seatbelt
  • schema-journal-sink — a journald-shaped endpoint (schema-journal-sink.c) that drains /dev/log to a plain per-service logfile with a journalctl shim; no journal database
  • schema-logind consolidation — one schema-logind process now serves login1 and the hostname1 / timedate1 / locale1 surfaces (sessions, seats, power, hostname, RTC/timezone, locale) — the "little-4" in a single readable program
  • schema-systemd1 — the full org.freedesktop.systemd1 management surface (ListUnits, GetUnit, Start/Stop/RestartUnit, per-unit ActiveState/SubState/MainPID/NRestarts, PropertiesChanged) served by its own schema-systemd1 process driving schema-ctl; systemctl status and Cockpit's Services page work
  • Built-in .svc timers — on_calendar= scheduling parsed in service.c retires cron and systemd .timer units (richer calendar forms tracked in docs/timers-design.md)
  • In-place migrator + COPR — schema-migrate --discover/--deploy/--uninstall converts a live Fedora KDE box to schema-init as PID 1 and back (proven end-to-end in a VM); prebuilt via dnf copr enable ajax80/schema-init (three packages), with a guided schema-init-wizard GUI
  • schema-dbus — native C broker replacing dbus-daemon on both the system bus and the session bus; enforces the dissolved busconfig policy (conformance-tested), does auth / name ownership / routing / match rules / unix-fd passing; serves a full KDE desktop as the live bus through reboots; opt-in, self-healing, reversible flip (on-demand activation, BecomeMonitor, full driver interface)
  • systemctl compat translator — a schema-systemctl shim (systemctl_shim.h) that makes packaged RPM/deb scriptlets (systemctl enable/daemon-reload/…) succeed on a schema-init box and records enable-intent to pending.list, plus a schema-import runtime importer that drains the queue and translates .service units into native .svc (ExecStart/Type/Restart/User/Environment with $VAR resolution, Type=notify → notify=1, Type=dbus → ready_bus_name=; Type=forking + PIDFile= → pid_file=; templates logged-and-skipped); dnf install/remove round-trip verified, generated units load under the real .svc parser
  • modules-load.d — PID 1 loads /etc/modules-load.d/*.conf at boot (systemd-modules-load parity)
  • schema-doctor — health checks with self-heal (boot-entry integrity, NM profiles bound to missing interfaces, session agents, powerdevil / ksycoca loops, panel pins), run at boot and periodically
  • Boot snapshots + GRUB fallback — known-good root snapshots with their own boot entries (separate /boot, initramfs and non-fstab /home handled) and a boot-success guard
  • PID 1 re-exec — schema-ctl reexec swaps the init binary in place with a dry run, sealed state handoff and rollback; RPM upgrades re-exec automatically, so updating PID 1 is just dnf upgrade, no reboot
  • Service watchdog — watchdog_sec= with WATCHDOG=1 over sd_notify (systemd WatchdogSec= parity): missed window → SIGABRT + core, then SIGKILL, then the normal restart arc
  • Fedora 44 installer ISO, verified on real hardware — netinst + kickstart installs KDE on schema-init as PID 1 with an optional guided schema-udev flip; clean install to desktop on a Dell i3 laptop with no hand fixes (v0.3.0)

Origin

schema-init was built by Jonathan Ayers in 2026 as the operating layer for the Ungulate Leg — an exoskeleton controller that needs a deterministic, schema-driven PID 1 carrying none of systemd's assumptions about what a computer is for. (A sister project, the Samara Wing, applies the same architecture to flight.) The reference hardware that proved it — a pawn-shop Dell Inspiron named Eli — booted to a full desktop under schema-init on May 30th, 2026. It was written alongside Claire, an AI (Claude, by Anthropic) that held continuity across the work.


License

AGPL-3.0-or-later for open source use — full text in LICENSE.
Commercial license available for embedding in proprietary systems — open an issue or contact via GitHub.


Built by Jonathan Ayers. The schema was written before the code.

aarch64
c
cgroups
dbus
embedded-linux
init
init-system
lightweight
linux
logind
no-systemd
pid1
process-supervisor
service-manager
state-machine
systemd-alternative
systemd-replacement
udev

ajax80/schema-init

A single static binary as PID 1 for Linux — services supervised by a weight-state machine, no systemd. Reclaims its satellite daemons too, as native opt-in replacements: logind, udev, dbus, journald. PID 1 holds 1–4 MB RSS in one thread, frees ~500 MB RAM, and idle cores reach 92–99% C10 deep sleep.

C

2

268 commits

updated Oct 7, 2026

See the code

See what people are saying

SourceMessageScoreDate

schema-init: a PID 1 written in C that boots Fedora 44 KDE. It imports your systemd units and keeps systemd in GRUB as a fallback (installer ISO) (r/linux)

I'm a plumber by trade. After I became disabled I started dabbling in code, and over the past few months that turned into schema-init, an init system (PID 1) written in C that I build and run on Fedora. To be upfront: I design it, run it on my own machines and break it on real hardware, and Claude…

0

Oct 7, 2026

README

schema-init

schema-init — 30-second trailer

▶ 30-second trailer — watch in full resolution, with sound

A minimal PID 1 init system for Linux that supervises services through a weight-state machine instead of unit files and dependency graphs — and, increasingly, a native, auditable replacement for the systemd daemons around it.

No systemd. No OpenRC. No journal daemon. No socket activation engine. At its core, just a statically linked binary that mounts your filesystems, spawns your services in dependency order, and watches them — then gets out of the way.

It doesn't stop at PID 1. systemd's satellite daemons don't have to be systemd's. schema-init ships small, single-purpose, native replacements you opt into one at a time: schema-logind (sessions, power, seats — plus the hostname1/timedate1/systemd1 D-Bus surfaces), schema-udev (device management, authoritative over /dev), schema-journal-sink (a journald-shaped endpoint that drains to a plain logfile — no journal database), built-in .svc timers that retire cron and systemd .timer units, and now schema-dbus — the D-Bus broker itself, serving both the system bus and the per-user session bus. Each is a program you can read end to end, runs only if you list it, and backs out with a single reboot. So you can reclaim the init layer piece by piece — or keep the stock daemons underneath and just run a leaner PID 1. The point isn't only less; it's an init layer you can actually read, top to bottom, and own.

PID 1 footprint: 1.2 MB RSS on a minimal boot, 3.3–4.0 MB running a 47-service KDE desktop — one thread, in every case. Every footprint figure in this README names the machine, the build and the service count it was measured on: see PID 1 RSS — every measurement.


What it gives back

systemd isn't just PID 1 — it's a constellation of always-on daemons: journald, systemd-logind, dbus-broker, systemd-resolved, resident udevd workers, timers firing on their own schedule. Each one holds RAM and wakes the CPU whether or not you're using it. schema-init replaces PID 1 with a single static binary and does none of that — no journal database, no socket-activation engine, no background event loops. What that machinery was holding comes back to you.

Your RAM comes back. On identical hardware running the identical desktop, schema-init frees roughly half a gigabyte of RAM that systemd's daemon stack was sitting on (~1.1 GB used at desktop vs ~1.6–2.0 GB — see Real numbers), and idle swap drops from hundreds of MB to zero. In lived terms that is the difference between a few browser tabs plus one other program before the machine starts thrashing and two or three browsers with ~20 tabs each and a game running at the same time — same RAM, no upgrade. The computer you already own effectively gets bigger.

Your power comes back. With no ambient timer wakeups holding the cores awake, the CPU actually reaches its deepest hardware sleep state: measured 92–99% C10 residency and ~1.25 W full-SoC package draw at a working desktop, idle load average 0.03 (vs 0.10–0.20 under systemd). Those figures are read from Intel RAPL hardware energy counters, not estimated. Per machine it is a small, honest number — but it is structural, paid back every second of every idle hour. schema-init's part is simply removing the constant wakeups that keep silicon out of deep sleep in the first place.

On extrapolating this: don't. These are single-node measurements on one i3 laptop. An init system's own power draw is a tiny slice of a server's total, so multiplying a per-node idle delta by a fleet size produces a number that will not survive contact with anyone who runs real hardware. If schema-init saves money at scale, the levers are density, footprint, boot time, attack surface and determinism — not init power draw.

The machine goes quiet, not just lean. One PID-1 thread instead of 20–30. A tick loop that sleeps indefinitely once services are stable — nothing wakes it on a schedule. No journal flush, no D-Bus polling, no watchdog chatter. The hardware is allowed to actually rest.

This isn't theory or a benchmark rig — it's a salvaged Dell Inspiron (Intel i3, 4 GB) that swapped constantly under systemd and now runs a full desktop with room to spare under schema-init. Older and low-RAM machines benefit the most: the daemons you delete are the exact ones a small machine can least afford.


How it works

Every service moves through a state machine driven by probes. Before a service is spawned, schema-init probes the system — is the binary present? Are dependencies stable? Is there enough memory? The probe returns a flag word. The state machine decides what comes next.

                  ┌─────────────────────────────────────┐
                  │                                     │
            NEW_PROCESS                                 │
                  │                                     │
           F8 probe passes                              │
                  │                                     │
            FULL_TRUST ──── stable 10s ──── FUNDAMENTAL │
                  │                         SETTLED     │
                  │                                     │
             (oneshot exit 0)                           │
                  │                                     │
              PERFECT                                   │
                                                        │
            ── on death ──                              │
                  │                                     │
             RECOVERY ◄──────────────────────────────── ┘
                  │
           F9 probe fails
                  │
             FRICTION
                  │
           F6 probe fails
                  │
             DORMANT  (75 — backoff anteroom: 5m → 10m → 20m → 40m → 60m)
                  │
         (non-critical, 5 cycles exhausted)
                  │
             EXCISED  (76 — gate closes)

Three probe families:

ProbeAsked whenChecks
F8Before first spawnBinary exists, deps stable, memory safe, permissions met
F9After deathRetry budget, cooldown window, memory, escalation path
F6After recovery failsLast-chance: can we even attempt a restart?

FULL_TRUST promotes to FUNDAMENTAL on the first readiness signal the service has: READY=1 over sd_notify (notify=1), its bus name appearing (ready_bus_name=), a path appearing (ready_path=), a forking daemon's launcher exiting 0 with a valid pid_file=, and otherwise surviving stable_secs (default 10).

Services marked critical=1 never reach EXCISED — they enter DORMANT and retry at 1-hour intervals indefinitely. Non-critical services excise after 5 dormant cycles (~75 minutes). A dep marked critical=1 that is EXCISED still blocks its dependents. A non-critical EXCISED dep is skipped — dependents proceed without it.


Quickstart

Replacing PID 1 sounds scary. It isn't, if you do it in the right order — you never lose your existing systemd boot, and you can back out with a single reboot at every step. Four lanes, safest first.

The easiest path — install from COPR (Fedora KDE)

On Fedora you don't have to build anything or write a USB stick. Enable the COPR and install:

sudo dnf copr enable ajax80/schema-init
sudo dnf install schema-init-migrate      # the CLI migrator + schema-udev, prebuilt
# optional GUI front-end:
sudo dnf install schema-init-wizard

This installs schema-init alongside systemd and changes nothing about how you boot — the packages just put the tooling on your system. To actually convert a Fedora KDE box, run the in-place migrator (proven end-to-end in a VM), which writes a non-default (schema-init) boot entry your normal Fedora still overrides:

sudo schema-migrate --discover                 # reads the system, changes nothing
sudo schema-migrate --deploy --prebuilt        # uses the packaged binaries, no compiler
# reboot, pick the (schema-init) entry; to undo:  sudo schema-migrate --uninstall

schema-init-wizard is a guided GUI wrapper around that same reversible flow with a two-reboot safety ladder. It's been VM-tested end to end (deploy, udev flip, dbus flip, and forced rollbacks of both) but hasn't yet been run on real hardware, so if you want the conservative route, use the schema-migrate CLI above; both do the same thing and back out the same way.

The COPR builds three packages: schema-init (the init), schema-init-migrate (the migrator + schema-udev), and schema-init-wizard (the GUI).

ajax80/schema-init only gets tagged releases. Every commit to master also builds into ajax80/schema-init-dev. That repo is untested and meant for the author's own machines, so don't enable it on a box you depend on.

The fast path — boot a prebuilt installer (no compiler, no Docker)

If you just want to see it run, grab the prebuilt Fedora 44 installer (schema-netinst44-installer-*.iso) from the latest release:

# download the .iso from the release, then:
sudo dd if=schema-netinst44-installer-<version>.iso of=/dev/sdX bs=4M status=progress oflag=direct && sync

Boot that USB stick (or point a VM at the ISO) and the installer gives you a full KDE desktop running schema-init as PID 1. It is a netinst image: the machine needs a network connection during install (it pulls the KDE package set). Verified on real hardware — a clean install on a Dell i3 laptop boots straight to Plasma with no hand fixes.

The install is package-managed: schema-init, -daemons, -session and -migrate are RPMs and the ajax80/schema-init COPR repo is enabled, so sudo dnf upgrade brings later builds and PID 1 re-execs onto them in place. -migrate also brings the flip tools and health checks (in /usr/libexec/schema-init), the systemctl stand-in, and the import of any systemd unit a later package installs. A box installed from the v0.4.1 ISO or earlier has the files but not the packages; convert it once, then reboot (the edited service files hold off reloads and re-execs until then):

sudo dnf copr enable ajax80/schema-init
sudo dnf install schema-init schema-init-daemons schema-init-session schema-init-migrate
sudo schema-ctl reexec && sudo rm -f /usr/local/bin/schema-ctl
sudo sed -i -E 's#^exec=/usr/local/lib/schema/(schema-dbus-run\.sh|schema-udev-flip-healthcheck\.sh|schema-dbus-flip-healthcheck\.sh)$#exec=/usr/libexec/schema-init/\1#' /etc/schema-init/services/*.svc
sudo sed -i 's#/usr/local/lib/schema/schema-flip-apply#/usr/libexec/schema-init/schema-flip-apply#' /etc/sudoers.d/schema-flip

The stock systemd boot entries stay in the GRUB menu as a fallback. SELinux is off while schema-init is PID 1, so files written then carry no labels, and the imported selinux-autorelabel-mark unit flags the disk for relabelling. The first boot of a systemd entry after running schema-init therefore relabels the whole filesystem and reboots once. That takes a few minutes and is expected; it is what makes the fallback safe to use.

After the first login a wizard offers the optional guided udev cutover — that step retires systemd-udevd and hands /dev to schema-udev. Wi-Fi and wired profiles are unpinned from systemd's interface names before the switch, so the network survives it. Once that's confirmed healthy, it offers a second optional step on its own reboot: switching both the system and session bus to schema-dbus, with the same automatic rollback if the bus doesn't come up.

⚠️ What the udev flip does: it kills systemd-udevd and makes schema-udev authoritative over device management. This is the whole point — watching your init own /dev — but it is a real change to how the box handles hardware. It's optional and guided; skip it and you still get schema-init as PID 1 with stock udev underneath.

After the restart, just log back into your desktop — it finishes the switch on its own; you don't have to click anything. If you arm the switch but then never return to the desktop, a headless seatbelt safely undoes it after the next boot and puts you back on stock udev — no damage, nothing to clean up. (One consequence of that safety net: it's a desktop on-ramp — a machine you run headless won't keep the flip, because the confirmation comes from the graphical session coming up.)

Prefer to build it yourself, or try it with zero risk to a real disk first? Take the lanes below instead — they compile from source and boot in a throwaway VM.

Requirements:

  • Build + test (Lane 0): gcc, make, pkg-config, libacl headers (libacl1-dev on Debian/Ubuntu, libacl-devel on Fedora — the udev uaccess tests link -lacl), and dbus-1 headers (libdbus-1-dev on Debian/Ubuntu, dbus-devel on Fedora — the default make target builds schema-dbus and make test compiles the sdbus tests against them). On Fedora the static link also needs glibc-static (Debian bundles libc.a in libc6-dev). Nothing else. The init itself is a single static binary with no runtime dependencies.
  • schema-logind: additionally python3-dbus + python3-gobject.
  • Build a bootable ISO (Lane 1): additionally Docker (or podman) — the ISO is built from a debian:bookworm container — plus squashfs-tools (mksquashfs), and network access to pull the base image. To boot that ISO in a window: qemu-system-x86_64, xorriso, socat.

Lane 0 — Build and test it (needs only a compiler, zero risk)

git clone https://github.com/ajax80/schema-init && cd schema-init
make            # build the static binary
make test       # ~30 unit tests: schema state machine, cgroup tiering, udev parity, …

No root, no Docker, no VM — this just proves the code compiles clean and passes its test suite on your machine. Start here.

Lane 1 — Watch it boot in a VM (still zero risk to your machine)

sudo scripts/make-iso.sh              # build a bootable schema-init ISO (needs Docker + squashfs-tools)
scripts/vmtest-gui.sh boot ~/schema-init.iso   # boot that ISO in QEMU (needs qemu + xorriso + socat)

make-iso.sh builds a full Debian + desktop live image with schema-init as PID 1 — it pulls a debian:bookworm container, so Docker must be installed and running and the first build downloads a few hundred MB. vmtest-gui.sh then boots the ISO you just built (it takes the ISO path as an argument — build it first). Nothing here touches your real bootloader or /dev; this is how you see it boot a real system before you trust it with yours.

Lane 2 — Install alongside systemd (reversible)

sudo ./setup.sh

The installer does not replace systemd. It compiles, installs the binaries, and:

  • installs a mount-fstab service that mounts your disks, swap, and bind mounts from /etc/fstab, read fresh at every boot (schema-mount-fstab: parents first, waits for each device up to x-systemd.device-timeout= — 90 s, or 10 s with nofail — and a nofail entry never fails the boot; noauto and _netdev entries are skipped). scripts/gen-mounts.sh writes the service plus a mount-fstab.sh snapshot to check;
  • optionally imports your enabled systemd services as .svc stubs so the box comes up running what it ran before (scripts/gen-services.sh);
  • writes a separate schema-init (fallback) GRUB entry and leaves stock systemd as the default.

Reboot, pick schema-init (fallback) from the boot menu, and try it. If anything is wrong, reboot and choose your normal systemd entry — you're back, untouched. Iterate on your service files, boot the schema-init entry again. In this lane systemd-udevd still runs; schema-init does not retire anything.

Run scripts/gen-mounts.sh (preview) before rebooting and read the mount-fstab.sh snapshot — confirm every mount is right. It shows what your /etc/fstab decides; the boot reads the fstab itself, so fix any mistake there.

Fedora KDE — the in-place migration wizard (turnkey Lane 2)

If your daily driver is Fedora KDE, schema-migrate is Lane 2 tuned for exactly that: it converts your running install in place, keeps your desktop working, and reverses with one command. It reads your system, builds and installs schema-init, ports the KDE session seam (seatd, schema-logind, autologin, PipeWire, polkit), bridges udev so the network and /dev come up, writes a non-default (schema-init) GRUB entry (your normal Fedora stays the default), makes the boot menu visible so you can pick it by hand, and heals the session gaps on first boot with schema-doctor.

git clone https://github.com/ajax80/schema-init   # e.g. onto a USB stick
cd schema-init
# preview only — reads the system, changes nothing:
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --discover
# deploy (builds from source; pulls gcc/make via dnf the first time):
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --deploy
# or, if schema-init is already installed (e.g. from the COPR package),
# skip the compiler entirely and use the packaged /usr/bin binaries:
sudo python3 distros/fedora-installer/migrate/schema-migrate.py --deploy --prebuilt

Reboot, pick the entry ending (schema-init) from the boot menu. To go back: boot your normal Fedora entry, then run the same command with --uninstall — it reverses exactly what it wrote (manifest-tracked) and leaves pre-existing packages alone.

Proven end-to-end in a Fedora-KDE VM (legacy BIOS): deploy → schema-init as PID 1 with a full Plasma desktop → --uninstall → back on systemd. v1 is Fedora KDE only; other distros and desktops are the next milestone. The generic, distro-agnostic version of this path is Lane 2 above (./setup.sh).

Lane 3 — Make it the default (once you trust it)

When the schema-init entry has booted cleanly a few times, make it default (set GRUB_DEFAULT / your distro's boot-entry default to it). Only then, if you want the full reclamation, opt into the authoritative udev cutover — a deliberate, checksum-backed, reversible flip that retires systemd-udevd. It is the advanced path; validate it in schema-vmtest LIVE mode first.

A one-reboot undo (btrfs roots). On a btrfs root, make safe-install snapshots / and /home into a writable sibling subvol with its own boot entry before installing — so a deploy that breaks the desktop is a 30-second rollback (pick the snapshot entry in GRUB, reboot) rather than a live-debugging session. No initramfs overlay needed. An optional boot-success guard (schema-bootok plus a grub.d hook) ties "the boot succeeded" to the desktop actually came up — not just PID 1 finishing — and auto-selects the last known-good snapshot after a failed boot. Setup and deploy flow: docs/boot-success-guard.md.

Porting to a machine that isn't yours yet (e.g. setting it up for someone else): on that machine, while it's still on systemd, run ./setup.sh --generate-profile <name> (no root, installs nothing) — it captures the machine's mounts and enabled services into a reusable distros/<name>/ profile. Commit it, then sudo ./setup.sh --profile <name> brings the box up with its own disks and services. See Porting to a new distro and the distros/ profiles.


Repository layout

If you're reading the source to evaluate it, start here. PID 1 is ~4,800 lines of C (the files below plus caps.c, ns.c, landlock.c and a few headers) with no external dependencies.

Read these first, in this order:

FileLinesWhat it is
init.c~2,400PID 1 itself. Mounts pseudo-filesystems, reaps children, runs the supervise loop, handles signals and shutdown. The spine — everything below is called from here.
schema.c / schema.h~140The weight-state machine. Pure state transitions; a service's "weight" is the popcount of its probe flag word. This is the schema — the single source of truth for what every state means.
service.c / service.h~1,650Parses .svc files, spawns services, runs the F8/F9/F6 probes, and drives the recovery → backoff → excision arc.
group.c / group.h~170Aggregates a .grp of services into one worst-case state, so a stack (network, display) promotes and fails as a unit.

Supporting binaries:

FileWhat it is
schema-ctl.cThe CLI client. Talks to PID 1 over the /run/schema-init.sock UNIX socket — schema-ctl status, restart, etc.
schema-subreaper.c~50-line helper that sets PR_SET_CHILD_SUBREAPER so a service can adopt its own orphaned grandchildren instead of dumping them on PID 1.
schema-journal-sink.cOpt-in Track B compatibility shim. Provides journald's three ingestion sockets (/dev/log, /run/systemd/journal/{socket,stdout}) and drains them to a plain logfile so foreign libsystemd/syslog software finds a journald-shaped endpoint. No journal DB, no journalctl. schema-init never needs it to boot. See docs/journal-sink-design.md.
schema-systemctl.c / systemctl_shim.hThe systemctl(1) compatibility shim. A drop-in that intercepts systemd verbs so packaged RPM/deb scriptlets succeed on a schema-init box: lifecycle verbs drive schema-ctl, enable/preset queue enable-intent to /var/lib/schema-init/pending.list for the importer (distros/fedora-installer/migrate/schema-import.py, which drains that queue and translates .service units into native .svc). See Running packaged software.
caps.c / ns.c / landlock.cService hardening applied in the child before exec: the keep_caps capability bounding set and no_new_privs (caps.c), the private mount namespace behind private_tmp / protect_system / protect_home (ns.c), and the landlock_ro / landlock_rw filesystem allowlist (landlock.c). See Hardening.
notify.hThe sd_notify readiness socket (/run/schema-init/notify): parsing, kernel-attested sender credentials, cgroup attribution.
schema-coredump.c / coredump.hThe core_pattern pipe helper that keeps crashes. See Crashes.
schema-udev.c + *_id.h, udev_*.h, disk_links.hThe native device manager. See schema-udev.
schema-dbus.c + sdbus_*.hThe native D-Bus broker for the system and session buses. See The system bus itself.
scripts/schema-snapshot, scripts/schema-bootokBoot snapshots and the boot-success guard (docs/boot-success-guard.md).
schema_shm.hThe shared-memory interface — PID 1 publishes live service state here so external tools can read it without polling the socket.
schema-board.cRead-only board that renders every service's weight-state in its LED colour, reading the shm export above rather than the control socket — so it keeps working when the socket or the desktop is wedged. --once prints one frame and exits. Reads a world-readable 0644 shm segment, so unlike schema-ctl it needs no root. --tty /dev/tty8 paints a dedicated console; note that VT switching does not currently repaint on a graphical system — see Recovery console. Increments 1–2 of the limp-mode recovery surface (docs/superpowers/specs/2026-06-14-limp-mode-design.md).

Directories:

DirWhat's inside
services/The reference service set — real .svc and .grp files for sshd, dbus, udev, network-manager, display-manager, and the network-stack / display-stack groups. Copy these as your starting templates.
desktop/schema-desktop.c — an SDL2 live visualizer that maps schema_shm.h into an 8-node grid and shows every service's weight-state in real time. This is how you watch the state machine run.
scripts/Build and integration tooling: make-iso*.sh / make-usb.sh / fix-usb.sh (bootable media), schema-logind.py (a logind compatibility shim), and verify_traceability.py (IEC 62304 requirement traceability).
distros/Per-distribution profiles — fedora-kde/ and raspberry-pi-zero-w/. Each carries the service files and boot glue that distro needs.
docs/, assets/Documentation and images.

Top-level: setup.sh (newcomer bootstrap — dep check, desktop-environment detection, GRUB entry generation) and Makefile (static build; see Building).


Service files

Drop a .svc file in /etc/schema-init/services/. One key=value per line:

name=sshd
exec=/usr/sbin/sshd
args=-D
needs_root=1
name=display-manager
exec=/usr/sbin/lightdm
dep=dbus
dep=udev
needs_root=1
critical=1
name=network
exec=/usr/local/bin/net-setup
oneshot=1

Keys:

KeyDefaultDescription
name(required)Service name — used in logs, dep resolution, and schema-ctl commands
exec(required)Absolute path to the binary to execute
args—Argument string (repeat the key for multiple args)
dep—Dependency by name (repeat for multiple deps; can name a service or a group)
oneshot0Exit 0 → PERFECT and don't restart; exit non-zero → RECOVERY arc
needs_root0Abort spawn if uid ≠ 0
critical0If 1: service never reaches EXCISED — stays DORMANT at 1h retry indefinitely. Also: if this service is EXCISED, its dependents are hard-blocked.
no_restart0Any death → EXCISED immediately; no recovery arc
max_restarts5Maximum number of crash-driven restarts before entering EXCISED or backoff. The first spawn, timer firings and schema-ctl restart do not count; a run of 5 minutes or more before dying resets the count
stable_secs10Seconds process must stay alive before FULL_TRUST promotes to FUNDAMENTAL. Set lower for fast services; use ready_path instead when possible
ready_path—Filesystem path that, when it exists, triggers immediate FULL_TRUST→FUNDAMENTAL promotion. Falls back to stable_secs if the path never appears. A file already at the path when the service spawns (left by a previous run) does not count until it is replaced or rewritten, so a respawn is not promoted on its predecessor's marker or socket. Directories are exempt. In FUNDAMENTAL it also acts as a liveness probe: if the path disappears, the service is killed and backed off. The disappearance check only arms once the path has been seen at least once — a service promoted by stable_secs before its path exists won't be falsely killed. For services slower than stable_secs to come up (e.g. NetworkManager writing resolv.conf), set stable_secs generously so promotion doesn't outrun the path.
oom_score_adj—Written to /proc/self/oom_score_adj before exec (-1000..1000); e.g. -900 keeps the kernel OOM killer off the bus, logind or udev. Every child inherits it, so never set it on a session launcher (sddm, autologin) or anything that spawns user apps — they would become unkillable. No value = the kernel default. schema-dbus resets its activated helpers to 0.
ready_bus_name—Promote FULL_TRUST→FUNDAMENTAL as soon as this well-known system-bus name gets an owner inside the service's cgroup (systemd Type=dbus / BusName=). The schema-dbus broker reports acquisitions to PID 1 over /run/schema-init/notify; PID 1 accepts such reports only from root running /usr/bin/schema-dbus. Falls back to ready_path, then stable_secs (e.g. with another bus broker).
notify0notify=1: the service gets NOTIFY_SOCKET (/run/schema-init/notify) and promotes FULL_TRUST→FUNDAMENTAL the moment it sends READY=1 (the sd_notify protocol, as systemd Type=notify daemons already do). A message counts only if the kernel-attested sender pid is in the service's cgroup; messages carrying fds are dropped. Falls back to ready_path, then stable_secs, so a daemon that never notifies behaves as before. STATUS= is recorded. A sender that has already exited can't be attributed (its pid may be reused), so a send-and-exit helper such as systemd-notify --no-block falls back too; plain systemd-notify --ready is attributed.
pid_file—For a daemon that forks into the background (systemd Type=forking with PIDFile=). When the launcher exits 0, PID 1 reads the daemon's PID from this file and supervises that process from then on: it counts as ready, its death enters the recovery arc, and stop and shutdown signal it. The PID must be alive and, when the service has a cgroup, inside it, so a stale or planted file is refused. A file written after the launcher exits is waited for up to 5 s; if none is valid by then the service's cgroup is killed and it retries. Needs the service's cgroup: without one there is nothing to check the PID against or to clean up with, so the service is excised (pid_file-no-cgroup).
watchdog_sec0Service watchdog (systemd WatchdogSec=). With notify=1 the service gets WATCHDOG_USEC/WATCHDOG_PID and must send WATCHDOG=1 (or be schema-ctl petted) at least every N seconds, counted from READY=1. A missed window sends SIGABRT (core dump via schema-coredump); still alive 90 s later, SIGKILL. The window is the one the running process was started with — a reload or re-exec that adds or changes watchdog_sec applies from the next spawn. A service being stopped is not watched. Either way it then restarts like any crash. The clock is held while the service is frozen under memory pressure. schema-import maps WatchdogSec= on Type=notify units. 0 = off.
watchdog_timeout_ms0Dead Man Token window in milliseconds. Service must call schema-ctl pet <name> (or send WATCHDOG=1) within this window or PID 1 stops kicking /dev/watchdog and the hardware resets. Use for critical=1 real-time processes. 0 = disabled.
cpu_limit0Percent of one CPU core (1–100) enforced via cgroupv2 cpu.max. Written before child exec. 0 = unlimited.
mem_limit0Memory hard cap in MB via cgroupv2 memory.max. OOM inside the cgroup kills the service, not the system. Written before child exec. 0 = unlimited.
prioritystandardCPU contention class via cgroupv2 cpu.weight: critical (weight 1000), standard (100), peripheral (10). Proportional share — only takes effect when cores are saturated; idle services are never penalized. The analog of systemd's CPUWeight=. Children inherit the service's cgroup, so tagging a session leader (e.g. display-manager) elevates its whole subtree, compositor included.
cpuset—CPU affinity list pinning the service to specific cores via cgroupv2 cpuset.cpus (e.g. 2,3 or 4-7). Constrains where the service may run, complementing priority/cpu_limit which govern how much. The analog of systemd's AllowedCPUs=. Requires the kernel's cgroupv2 cpuset controller (delegated automatically); if absent the setting no-ops. Empty = unconstrained (inherits the parent's CPUs). Useful for isolating a latency-sensitive control loop from cores that bursty background work hammers.
cpuset_partitionmemberExclusivity tier for the cpuset= cores via cgroupv2 cpuset.cpus.partition. member (default) = plain pinning, cores stay shared (no-op). root = the cores become an exclusive partition (no other service may run on them) while still being scheduler-load-balanced. isolated = exclusive and removed from the scheduler's load balancer — dynamic isolcpus=, no kernel cmdline needed; the target for a latency-critical control loop (the Ungulate Leg) or the audio path. Implemented as a cgroup v2 remote partition: schema-init reserves the cores in its own cpuset.cpus.exclusive and the service forms the partition, so the other services are unaffected. If the kernel rejects the partition (overlapping cores between two isolated services, or no cpuset controller) the service silently degrades to plain cpuset= pinning and a HAZARD line is logged — boot is never blocked. Requires a non-empty cpuset=; setting it alone is ignored with a warning.
allowed_slot_min-1Minimum hardware slot ID (inclusive) this service is permitted to run on. Checked against SLOT_ID env at spawn time. -1 = unconstrained.
allowed_slot_max-1Maximum hardware slot ID (inclusive). If SLOT_ID falls outside [allowed_slot_min, allowed_slot_max], spawn is refused with a HAZARD log and SVC_NO_RESTART is set — the service will not retry. Both min and max must be ≥ 0 to activate the gate.
on_boot_sec0Makes the service a timer: seconds after boot before the first fire (0 = at boot). Implies oneshot=1 — the service runs, exits, and re-arms. The analog of systemd's OnBootSec=. See Timers below.
on_active_sec0Timer period: seconds after each completion before the next fire. Measured from completion (like systemd's OnUnitInactiveSec=), so a slow run never overlaps itself. Implies oneshot=1.
start_timeout_sec90 for oneshots, 0 otherwiseMax seconds a service may sit in FULL_TRUST without promoting before it is killed and routed into the recovery arc — so a hung boot service can't stall its dependents. Defaults on for oneshots (the only services that can hang the chain; daemons promote via stable_secs). Timers are exempt (may run long). 0 disables. The analog of systemd's TimeoutStartSec=.
stop_timeout_sec3Seconds a service gets to exit after SIGTERM at shutdown before its cgroup is killed (1–300). Raise it for anything that saves state on the way out: the Plasma session gets 20, because plasmashell can need 12 s to finish writing a freshly built panel layout. The analog of systemd's TimeoutStopSec=.
stop_first0At shutdown, this service gets SIGTERM before any other, and nothing else is stopped until it has exited (or its stop_timeout_sec ran out). For the desktop session: its apps talk to system daemons on the way out (KDE's printer client to CUPS, for one) and hang if those are stopping at the same time. systemd gets the same effect by stopping user sessions before system services.
user—Run as this user (and its primary group, with its supplementary groups) instead of root. Resolved when the file is loaded.
env—KEY=VALUE set in the child before exec (repeat for several, up to 16). There is no shell, so no expansion.
no_excise0Like critical=1 for the excision gate only: the service backs off in DORMANT forever instead of reaching EXCISED, without hard-blocking its dependents the way a critical dep does.
fuse0Quarantine fuse: if any dependency enters FRICTION or EXCISED, kill this service and excise it at once (for something that must not run on a broken foundation).
fuse_cmd—Shell command (sh -c) run when the fuse trips.
failsafe—Shell command (sh -c) run when the service dies unexpectedly or hits start_timeout_sec: drive an actuator to a safe position, raise an alarm. $INSTANCE is set for template instances. One at a time per service.
failsafe_timeout_ms500The failsafe command is SIGKILLed after this long.
ready_poll_hzloop rateHow often a FUNDAMENTAL service's ready_path liveness check runs, when it should be slower than the main loop.
(default)Services restart automatically through the F9/F6 recovery arc unless no_restart or oneshot is set

A full example using readiness probes:

name=dbus
exec=/usr/bin/dbus-daemon
args=--system
args=--nofork
needs_root=1
stable_secs=2
ready_path=/run/dbus/system_bus_socket

Hardening

Per-service confinement, applied in the child between fork and exec. Each is opt-in per .svc; a host-wide switch can default them on.

KeyDefaultDescription
no_new_privs0PR_SET_NO_NEW_PRIVS: setuid binaries and file capabilities can no longer raise privilege in the service or anything it runs.
keep_caps—Comma-separated capability names (CAP_NET_BIND_SERVICE,CAP_SYS_TIME); every other capability is dropped from the bounding set. An unknown name refuses the load.
private_tmp0Fresh /tmp and /var/tmp for this service (private mount namespace).
protect_system01: /usr, /boot, /efi read-only. full: /etc too.
protect_home0/home, /root and /run/user replaced by empty read-only mounts.
landlock_ro / landlock_rw—One absolute path per line, repeatable (up to 16). If any is set, the service can reach only the listed paths (Landlock): _ro allows read and execute beneath the path, _rw allows everything. Root is confined too. The service's exec, its shared libraries and anything it reads (/etc/ld.so.cache, /proc, /dev/null…) must be listed. A path that doesn't exist is skipped. The load is refused if exec isn't covered, or if keep_caps drops CAP_SYS_ADMIN without no_new_privs=1 (the kernel requires one of them). Applied after the uid/gid lookups and just before setuid. In a drop-in, an empty landlock_ro= or landlock_rw= clears that list. Needs a kernel with Landlock enabled; without it the service fails to start.

Host default. /etc/schema-init/hardening-default containing on, or schema.hardening_default=1 on the kernel command line (which wins, both ways), turns no_new_privs, private_tmp, protect_system=1 and protect_home on for every service that doesn't set them. An explicit key=0 opts a service out. A defaulted private_tmp or protect_home that would hide the service's own exec or ready_path is dropped with a log line; an explicit one refuses the load. The switch is off unless you turn it on. schema-ctl status <svc> shows each knob's value and whether it came from the file, the default, or was dropped.

A hardening step that fails aborts the spawn (HARDENING FAILED in the service log) rather than running the service unconfined.

Service templates

For fleets of identical services — e.g. 49 joint controllers on an exoskeleton — define config once and symlink instances:

# template — write once
/etc/schema-init/services/motor@.svc

# instances — zero-byte symlinks; suffix becomes $INSTANCE in the child
ln -s motor@.svc /etc/schema-init/services/motor@0.svc
ln -s motor@.svc /etc/schema-init/services/motor@12.svc
ln -s motor@.svc /etc/schema-init/services/motor@48.svc

At boot, motor@.svc is skipped as a non-spawnable template. Each motor@N.svc symlink loads config from the template and spawns the binary with INSTANCE=N in the child environment. The motor controller reads $INSTANCE to determine its joint index, SPI bus address, or any other per-instance identity — no per-node config files required.

If a node runs the bare template directly (e.g. on a slot-detected Pi Zero W 2 where the node's identity comes from GPIO strapping), INSTANCE falls back to SLOT_ID from /run/schema-init/env. One SD card image serves the entire fleet.

AllowedSlot gate — for hardware deployments where running the wrong firmware on the wrong node is a physical hazard, add slot constraints to the template:

name=motor
exec=/usr/local/bin/motor-ctrl
allowed_slot_min=16
allowed_slot_max=27

If SLOT_ID is outside the declared range at spawn time, schema-init logs a HAZARD line, refuses the spawn, and sets SVC_NO_RESTART. The process never runs. Project Daedalus slot map:

Slot rangeJoint
0–7Hip Left
8–15Hip Right
16–21Knee Left
22–27Knee Right
28–33Ankle Left
34–39Ankle Right
40–43Toe Left
44–47Toe Right
48Supervisor

Dependencies are resolved by name at load time. A service stays in NEW_PROCESS until all its deps reach FUNDAMENTAL, SETTLED, or PERFECT. A dep name can refer to either a service or a group (see below).

Group files

Drop a .grp file in the same services directory to create a named group. Services can depend on a group name just like a service name.

name=storage
member=lvm
member=cryptsetup
member=mount-data

A group's state is the worst-case view of its members:

  • Any member EXCISED → group is EXCISED
  • Any member in FRICTION/RECOVERY → group reflects that
  • All members FUNDAMENTAL or better → group is FUNDAMENTAL
  • All members PERFECT → group is PERFECT

Maximum 16 groups, 8 members per group. Names and members are matched at load time.

Timers

Add on_boot_sec and/or on_active_sec to any .svc to make it periodic — no separate .timer file, no second unit to link. The service is the timer. This replaces cron and systemd .timer units with the same .svc you already wrote.

name=fstrim
exec=/usr/sbin/fstrim
args=-a
needs_root=1
on_boot_sec=600        # first fire 10 min after boot
on_active_sec=86400    # then every 24 h after each completion

A timer is a oneshot that re-arms on a CLOCK_MONOTONIC deadline instead of staying terminal at PERFECT:

  • It boots into PERFECT (as if it already ran), first fire at boot + on_boot_sec.
  • On fire it re-enters NEW_PROCESS — so dependencies are still honored and it waits for its deps exactly like any service.
  • When it exits, it re-arms for now + on_active_sec regardless of exit code (cron semantics — a failed run is not retried in a loop; it runs again next window). The exit is logged timer-done or timer-failed.

Run-once: set only on_boot_sec (leave on_active_sec unset) and the service fires exactly once, on_boot_sec seconds after boot, then stays terminal — a deferred startup job rather than a repeating one.

The period is measured from completion, so a slow job never overlaps itself. Fires on the 250 ms tick (±1 tick) — cron-class precision, not sub-second. For real-time work use watchdog_timeout_ms and the control loop instead.

Wall-clock timers — set on_calendar=HH:MM to fire at a fixed local time every day, on_calendar=Mon HH:MM for weekly, or on_calendar=15 HH:MM for a day of the month, the way you'd write a cron line. This is the form you want for "3am backup", "midnight log rotation", "nightly cert renewal":

name=nightly-backup
exec=/usr/local/bin/backup.sh
needs_root=1
on_calendar=03:00     # fire at 03:00 local time, every day

on_calendar re-evaluates the wall clock on every fire, so it tracks CLOCK_REALTIME (not monotonic) — DST shifts and NTP clock steps self-correct each cycle rather than drifting. Time is local (/etc/localtime). Malformed values are logged and ignored, never scheduled.

Catch-up after downtime — by default a job missed while the machine was off simply runs at its next occurrence. Add persistent=1 to run it once at boot instead, if its scheduled time passed while the system was down (systemd Persistent=true):

name=nightly-backup
exec=/usr/local/bin/backup.sh
needs_root=1
on_calendar=03:00
persistent=1          # if 03:00 was missed while off, run at next boot

Last-run is stamped to /var/lib/schema-init/timers/<name>.stamp; at boot, if the most recent scheduled occurrence is newer than that stamp, the timer fires immediately (logged timer-catchup) instead of waiting. A never-run timer is seeded rather than replayed, so enabling one doesn't trigger a surprise fire on first boot. persistent=1 only applies to on_calendar timers; on an interval timer it's logged and ignored.

Not yet implemented: several times per day, ranges and lists in one key. See docs/timers-design.md.


schema-udev

schema-udev is a native uevent→schema→action daemon — a small, purpose-built device manager that watches the kernel uevent stream and runs your hook scripts when a match fires. It runs in one of two modes.

Shadow mode (default)

The safe default: schema-udev runs alongside real systemd-udevd, not replacing it. udevd still owns /dev population, symlinks, and driver binding; schema-udev just watches the same kernel uevent stream and fires your rules for the specific devices you care about (an ESP32 over USB-serial, an RFID reader, a sensor board).

It binds kernel netlink group 1 (UDEV_MONITOR_KERNEL), never group 2 (UDEV_MONITOR_UDEV) — group 2 is udevd's own processed-event multicast, consumed by libudev/PipeWire for device enumeration. In shadow mode schema-udev only listens to the raw kernel group, so it has zero observed impact on PipeWire or desktop hotplug. Datagrams are checked against the kernel's SCM_CREDENTIALS (uid 0, pid 0) before being parsed, so a spoofed unprivileged uevent is dropped. Rule syntax, coldplug, and hooks below apply to both modes.

Authoritative mode (the udevd cutover)

The endgame of schema reclamation: schema-udev retires systemd-udevd and owns /dev itself. This is a deliberate, reversible opt-in — off unless you explicitly arm it — because it moves device management for the whole machine under schema-init.

  • Arm it by placing the sentinel flag /etc/schema-init/schema-udev.live and restarting the daemon; on the next launch schema-udev takes authority and systemd-udevd is retired. Its startup log records mode=LIVE (owns real /dev, /dev/disk, ACLs) so the mode is never ambiguous.
  • In LIVE mode schema-udev takes on everything libudev/PipeWire/logind expect from a udev: device-node creation (native mknod, RUN{builtin} kmod loads, ATTR{} sysfs writes), the /dev/disk/by-* + /dev/char + /dev/block symlink farm, uaccess seat ACLs, the group-2 libudev monitor broadcast, and the /run/udev/data device database — the two interop encoders that are dormant in shadow mode are wired live here.
  • Rules are interpreted natively from the distro's *.rules (TAG+=, SYMLINK+=, PROGRAM/RESULT, IMPORT, native *_id builtins) in addition to schema-init's own *.dev files, so tag/symlink-dependent consumers (FIDO2/pico-fido ACLs, snaps, power-button handling) keep working after udevd is gone.
  • Rollback is blessed and checksum-asserting: a verified pristine backup of the stock device path is restored on disarm (remove the sentinel), so a bad cutover reverts cleanly to systemd-udevd authority on the next boot. Keep the backup — the sentinel protects the machine; it does not un-take authority on its own.

⚠️ Authoritative mode is the advanced path. Validate it in schema-vmtest LIVE mode (prove it boots owning /dev) before trusting it on hardware you can't easily recover, and confirm the by-* symlink set your mounts depend on is complete for your disks.

Rule files live in /etc/schema-init/dev/*.dev, one key=value per line:

name=esp32-serial
match_subsystem=tty
match_product=10c4/*
symlink=esp32
on_add=/usr/local/bin/esp32-up.sh
on_remove=/usr/local/bin/esp32-down.sh
  • match_* keys map to raw kernel uevent keys (match_subsystem → SUBSYSTEM, match_product → PRODUCT, etc.), ANDed together — a rule only fires when every match_* key it declares matches. Values support fnmatch(3) globs (10c4/*).
  • symlink=<name> creates a stable symlink /dev/schema/<name> → /dev/<DEVNAME> on add, unlinking it on remove. Created atomically prior to on_add hook execution. Name must be single-level (no / or .., max 63 chars) under the parallel /dev/schema/ namespace to avoid writer contention with systemd-udevd.
  • on_add / on_remove are hook commands run via /bin/sh -c with the full uevent exported as environment variables — ACTION, DEVNAME, DEVPATH, PRODUCT, MODALIAS, and whatever else the kernel sent.
  • Coldplug at startup: On launch, schema-udev performs an in-process physical sysfs walk (/sys/devices) to synthesize events for devices already present at boot and fire on_add rules and symlinks without touching netlink or /sys/*/uevent files (ensuring zero systemd-udevd or desktop disturbance).
  • Comments must be on their own line (# as the first non-blank character). There is no inline-comment stripping — a trailing # note after a value becomes part of the value. See assets/example.dev (fully inert — every line commented, safe to drop in as a template) and copy it to /etc/schema-init/dev/<name>.dev to activate.
  • SIGHUP reloads all rule files from disk without restarting the daemon (schema-ctl reload or kill -HUP on its pid).
  • Raw kernel (group 1) uevents deliver DEVNAME without the /dev/ prefix (e.g. DEVNAME=ttyUSB0, not /dev/ttyUSB0) — don't anchor match_devname globs to /dev/, and hooks see $DEVNAME the same unprefixed way. Prefer keying on match_subsystem + match_product (vid/pid), as in the example above.

libudev / /run/udev interop

schema-udev carries pure encoders for the two formats a udevd retirement needs: the libudev monitor netlink frame (group 2) and the /run/udev/data device-database record, unit-tested against real captured frames. In shadow mode they are dormant — schema-udev neither broadcasts on group 2 nor writes /run/udev while systemd-udevd runs, since doing so would double libudev events and corrupt udev's database. In authoritative mode (see above) they are wired live: schema-udev is the sole writer of /run/udev/data and the sole group-2 broadcaster, which is what lets libudev/PipeWire/logind keep enumerating devices with udevd gone.


schema-doctor

schema-doctor is a self-healing seam checker. schema-init's replacements (its own logind, its own udev) meet desktop expectations at a handful of seams, and every machine comes up with different but very similar quirks clustered there — a compositor that raced ahead of its device ACLs, a power daemon that never autostarted, a menu-cache rebuild storm. The doctor diagnoses those seams, heals the safe ones itself, and names the deep ones (too invasive to fix under a live desktop — the real fix belongs at the source). It is a stdlib-only script, installed at /usr/local/bin/schema-doctor, that uses the still-installed systemd as its oracle for "what should this look like."

The checks

Each check is graded SAFE (auto-healed on every run) or DEFERRED (detected and named, never auto-healed — run with --force <name> to override):

CheckGradeCatches
card-input-aclSAFEactive user missing rw on a node the udev DB tags uaccess (drm card*, joysticks, SDR/FIDO) → re-applies the ACL
vt-mediationSAFECtrl+Alt+F<n> VT switching unmediated (the frozen-screen path) → re-arms it via schema-logind
session-singleDEFERREDan orphaned placeholder session — registration lost the boot race
login1-powerDEFERREDlogin1 not answering the PowerOff/Reboot/Suspend/Hibernate/inhibitor queries PowerDevil makes on load
powerdevil-runningDEFERREDPowerDevil not running, so power and screen-lock settings won't load
ksycoca-loopDEFERREDplasmashell and kded6 disagree on XDG_MENU_PREFIX, each rebuilding the menu cache in a loop

The run loop is the safety heart: snapshot → heal → verify, per check. If a heal doesn't resolve the fault, or breaks a previously-clean check, it is backed out and reported not-healed; a collateral break aborts the whole run. The entire run is wrapped to always exit 0 (critical=0) — the doctor can never block boot or leave the box worse than it found it. Config lives in /etc/schema-init/doctor.conf (heal=no for global detect-only, disable=a,b, notify=no).

Two runs, one engine

The same one-shot engine runs in two roles — it is not a daemon:

  • Boot run (schema-doctor.svc, --heal --wait 30): a late oneshot after a session exists, aggressive first-heal on a fresh box, silent.
  • Standing run (schema-doctor-periodic.svc, --heal --periodic): a native schema-init interval timer (on_boot_sec/on_active_sec, re-armed by PID 1) re-runs the engine every 10 minutes to catch faults that appear after login — a helper that dies at hour 3, an ACL a bad app clobbers mid-session.

The standing run adds three fail-safe guards:

  • Flap guard — heal history lives in /var/lib/schema-init/doctor-state (JSON, keyed by boot_id). A check healed ≥3 times in 30 minutes is marked CHRONIC: the doctor stops band-aiding it and escalates it to a visible RED, because the real fix is elsewhere. The mark clears the first time the check reads clean.
  • Health signal — /run/schema-init/doctor-status (text, plus a --json twin) is rewritten every run: per-check GREEN/AMBER/RED with an overall worst-wins rollup. schema-doctor --status prints it from anywhere.
  • Desktop notifications — edge-triggered on --periodic runs only: a check newly going CHRONIC or heal-failing, or the box returning to all-GREEN. Successful auto-heals are silent — quiet self-healing is the point. Notifications reach the active user by reading DBUS_SESSION_BUS_ADDRESS from the session leader's environ (this platform's bus is /tmp/dbus-XXXX, not /run/user/1000/bus), and are best-effort — a notify failure never fails a run.

schema-doctor is the logind-seam counterpart to what verify-rules-live is for the udev seam: a standing assertion that the seam still holds.


State glossary

StateMeaning
NEW_PROCESSQueued. Waiting for all deps to reach FUNDAMENTAL. No spawn attempt yet.
FULL_TRUSTSpawned. Watching — promotes to FUNDAMENTAL on READY=1 (notify=1), when its ready_bus_name gets an owner, when ready_path exists, or when stable_secs elapses, whichever comes first.
FUNDAMENTALStable. Load-bearing. Other services can depend on it.
SETTLEDStable, non-critical. Satisfies deps but generates no friction warnings if lost.
RECOVERYDied unexpectedly. F9 probe running. May re-queue or escalate.
FRICTIONRecovery failed. F6 last-chance probe running.
DORMANTF6 failed. Exponential backoff: 5m→10m→20m→40m→60m. Re-queues on wake. critical=1 services never leave this toward EXCISED.
EXCISEDPermanently removed. Non-critical only, after 5 dormant cycles. Gate closes.
PERFECTOneshot service exited 0. Terminal success.

Shutdown

schema-init handles shutdown signals from userspace or the kernel:

sudo kill -TERM 1   # poweroff
sudo kill -INT 1    # reboot

On SIGTERM, schema-init sets system state to shutdown, sends SIGTERM to all child processes, waits 500ms for clean exit, then calls reboot(RB_POWER_OFF).

On SIGINT, same sequence ends with reboot(RB_AUTOBOOT).

The 500ms hold is intentional — it gives any running desktop or display manager time to render a shutdown state before the process tree is torn down.


Known limitations

These are real gaps, not future features being teased:

  • Young, and not independently audited. schema-dbus and schema-logind sit on the system's authorization boundary — who may own a bus name, who may power off, who gets device access. Both are fuzzed and covered by tests, but neither has had an outside security review. Weigh that before running them on a machine other people log into.
  • No socket activation — services must manage their own sockets. There is no systemd-style socket hand-off (LISTEN_FDS).
  • Log rotation is not scheduled by default. The logrotate config ships, and an example timer (services/logrotate.svc.example) ships alongside it, but nothing fires the rotation until you enable that timer. See Logs.
  • schema-logind.py reimplements a subset of org.freedesktop.login1. It models multiple concurrent sessions and seats — a session registry with one object per session, per-seat membership, and active-session tracking — which is enough for a Wayland compositor to take KMS and hand it back on a VT switch (see Recovery console) and for uaccess device ACLs to follow the active session. It is a targeted reimplementation, not the full daemon. It also does not set KDSKBMODE = K_OFF, deliberately — if the daemon died while K_OFF were set the console keyboard would stay dead, and K_OFF would also disable the kernel's ctrl-alt-F VT switch that is the recovery-console escape hatch — so keystrokes still reach the tty underneath a compositor. They are neutralised rather than blocked: when the session VT is handed to the compositor, echo is turned off and pending input is flushed, and no getty runs on the session VT to read what arrives.

Filesystem setup

schema-init does not parse /etc/fstab. On boot it mounts the pseudo-filesystems directly:

MountTypeNotes
/remount rwKernel mounts rootfs read-only for fsck; schema-init remounts it writable before anything else
/procprocnosuid, nodev, noexec
/syssysfsnosuid, nodev, noexec
/devdevtmpfsnosuid, strictatime
/dev/ptsdevptsnosuid, noexec, gid=5,mode=620,ptmxmode=666 — without it there are no PTYs and every terminal emulator fails to start
/dev/shmtmpfsnosuid, nodev, mode=1777 — POSIX shared memory
/runtmpfsnosuid, nodev, mode=0755
/sys/fs/cgroupcgroup2nosuid, nodev, noexec, relatime

After mounting /dev, schema-init creates four symlinks that devtmpfs does not provide and a userspace init is expected to make itself:

LinkTarget
/dev/fd/proc/self/fd
/dev/stdin/proc/self/fd/0
/dev/stdout/proc/self/fd/1
/dev/stderr/proc/self/fd/2

Without these, bash process substitution (< <(...)) and any /dev/stdin-style redirect fail — a gap that surfaces in ordinary shell scripts long before it surfaces anywhere in the init itself.

schema-init also creates /run/log/schema-init/ at boot. Each service's stdout and stderr are redirected there automatically (see Logs).

If your system needs additional mounts (data partitions, network filesystems), run them as oneshot services before your other services depend on them.

Mount by UUID= or LABEL=, never /dev/sdX. The kernel assigns sda/sdb/… in detection order, which can change between boots — so a oneshot that mounts /dev/sdb1 may silently land on the wrong physical disk, swapping two data drives and pointing every absolute path at the wrong filesystem. /etc/fstab under systemd hid this by mounting by UUID for you; schema-init doesn't read fstab, so do it explicitly: mount UUID=1b7d654f-… /mnt/data (or LABEL=). The same applies to the root LABEL=/PARTUUID= on the kernel cmdline.


Building

make

Produces a fully static binary — no glibc version dependency, runs on any Linux kernel. Tested on:

  • Debian Bookworm, kernel 6.1, x86_64 — headless and Cinnamon desktop
  • Fedora 44, kernel 7.0, x86_64 — full KDE Plasma desktop, btrfs subvolume boot

Cross-compile for aarch64 (ARM — Ungulate Leg target):

make aarch64

Requires aarch64-linux-gnu-gcc. On Fedora: sudo dnf install gcc-aarch64-linux-gnu. Produces static schema-init-static, schema-ctl, and schema-subreaper binaries. Override sysroot with SYSROOT=/path/to/sysroot make aarch64.

ARM bare-metal (Pi Zero W, armv6l):

Fedora's arm-linux-gnu-gcc cross-compiler does not ship an arm sysroot. Compile natively on the Pi:

sudo apt install git gcc make
git clone https://github.com/ajax80/schema-init
cd schema-init && make

The armhf Makefile target exists for environments that have a full arm sysroot available.

schema-desktop (optional SDL2 monitor):

make desktop
sudo cp desktop/schema-desktop /usr/local/bin/schema-desktop

Requires SDL2 and SDL2_ttf. On Fedora: sudo dnf install SDL2-devel SDL2_ttf-devel. Reads live service state from PID 1's shared memory segment — run it from the desktop after login, or drop distros/*/config/autostart/schema-desktop.desktop into ~/.config/autostart/ to launch it automatically.

# install as PID 1 — symlink approach (distro-compatible)
cp schema-init /sbin/schema-init
ln -sf /sbin/schema-init /sbin/init

# or pass to kernel directly via GRUB
linux /boot/vmlinuz root=LABEL=my-root init=/sbin/schema-init

GRUB setup

Option A — symlink (/sbin/init → /sbin/schema-init): works with any distro GRUB config, no kernel cmdline change needed. Replace your distro's init binary or point the symlink.

Option B — explicit init= in GRUB: add init=/sbin/schema-init to the kernel line in /etc/default/grub, then grub-mkconfig -o /boot/grub/grub.cfg (Debian/Ubuntu) or grub2-mkconfig -o /boot/grub2/grub.cfg (Fedora).

Option C — custom GRUB menu entry: create a separate entry that leaves the distro default untouched:

# /boot/grub/custom.cfg  (included automatically by grub.cfg)
menuentry 'schema-init' {
    search --no-floppy --label --set=root schema-root
    linux   /boot/vmlinuz-$(uname -r) root=LABEL=schema-root rw quiet init=/sbin/schema-init
    initrd  /boot/initramfs-$(uname -r).img
}

Option C is the safest for dual-boot or first-time installs — it leaves the existing systemd entry intact as a fallback.

No initramfs? The root spec must be kernel-native. schema-init happily boots with no initramfs (the kernel hands straight to PID 1), but then nothing resolves a filesystem-level root spec for you — root=UUID=… and root=LABEL=… are resolved by dracut/udev from inside the initramfs, which no longer runs. A no-initramfs kernel can only resolve a kernel-native device: root=PARTUUID=… (GPT partition UUID, from blkid -s PARTUUID -o value /dev/…), root=PARTLABEL=…, or root=/dev/…. If you drop the initrd line above, you must also switch root= to one of these, or the kernel panics before PID 1 with VFS: Unable to mount root fs on unknown-block(0,0) — schema-init never gets to run. Keep rootflags=subvol=root (or your subvol) for a btrfs root. The fs-UUID and the PARTUUID are different values; don't paste one where the other belongs.

Kernel cmdline words are safe. The kernel hands PID 1 every boot-cmdline token it didn't consume (rhgb, quiet, splash, plymouth.debug, …), so leave your usual options in the kernel line — schema-init ignores them when it runs as PID 1. A services directory other than the default /etc/schema-init/services can only be set by hand-running the binary (schema-init /path/to/services), never via the kernel cmdline.

Replacing a running init (without reboot)

PID 1 can swap itself onto a new binary in place. Services keep running with the same pids, restart counts, timers and readiness state, and the desktop session never notices:

sudo schema-ctl reexec            # re-exec into the binary PID 1 booted from (now the new one on disk)
sudo schema-ctl reexec /path/bin  # or into a specific absolute path
# ok: re-executed into 0.4.0-1.990…, 60 services adopted, 0 new

With the RPM, you don't need to run it. On an upgrade, the package re-executes PID 1 itself once the transaction finishes, so dnf upgrade is the whole update. It is skipped in a chroot or container (the installer, mock) and when PID 1 isn't schema-init. A refusal is printed and never fails the transaction.

How it stays safe:

  • Dry run first. The new binary is started as a child with --reexec-check and must accept the state before PID 1 commits. A binary that crashes, is the wrong program, or can't read the state is refused, and the running PID 1 is untouched.
  • State is handed over, not re-derived. The runtime half of every service is serialized as versioned text into a sealed memfd. The control, notify and watchdog fds are inherited. The new image skips boot-only work (mounts, module loading, /tmp lock cleanup, watchdog arming) and adopts the running children.
  • Fallback. If the new image fails after the swap, it re-executes the old one (held open by fd) and the command replies err: rolled back.
  • Refused when a .svc changed since boot (those changes need a reboot), when the system is under memory pressure, or for a relative path. schema-ctl exits 1 on any refusal.

Measured on blakbox (60 services, full Plasma session): 53 ms per re-exec, schema-ctl status byte-identical before and after.

One caveat: a PID 1 older than re-exec support (COPR before 0.4.0-1.983, git before v0.4.0-31) has no reexec verb, so moving off it takes one last reboot.

If you install the binary by hand, it can't be overwritten while it runs (text file busy). Copy it next to the old one and mv it over, which replaces the directory entry atomically, then re-exec:

cp schema-init /usr/bin/schema-init.new
mv /usr/bin/schema-init.new /usr/bin/schema-init
sudo schema-ctl reexec

Real numbers

Tested on Dell Inspiron 3542 (Intel Core i3, 4GB RAM) running full Cinnamon desktop:

Metricschema-initsystemd (same hardware, Fedora)
PID 1 RSSsee PID 1 RSS — every measurement(not measured on this machine)
PID 1 threads120–30+
RAM used at desktop~1.1 GB~1.6–2.0 GB
Swap used0 MB200–500 MB
Time to desktop~20.7sslower

The gap is structural. schema-init spawns your services and then sits in a 250ms tick loop. There is no journal daemon, no dbus-broker, no socket activation layer, no unit file parser running in the background.

Boot timing breakdown (Dell Inspiron 3542, Debian Bookworm, kernel 6.1.0-49, times relative to PID1 start):

kernel → PID 1:    6.968s
dbus               1.761s   (ready: /run/dbus/system_bus_socket)
elogind            2.739s   (ready: /run/systemd/seats)
polkitd            3.447s
udev               3.197s
network           10.505s   (oneshot)
network-manager   11.757s
getty-tty1        10.755s
sshd              10.755s
display-manager   13.760s   ← LightDM login screen visible

total kernel → login screen: ~20.7s

schema-ctl timing produces this output.

Fedora 44 KDE, installed from the ISO: systemd vs schema-init

Measured 2026-09-25 on an HP 15-bs2xx (Celeron N4000, 4 GB RAM, 5400 rpm HDD): a fresh v0.3.1 ISO install with the schema-udev flip done, fully dnf upgraded, kernel 7.2.7 on every run. Both sides autologin into the same Plasma 6 session. Each run is a warm reboot into the named GRUB entry (grub2-reboot); readings are taken 4 minutes after boot by tests/livetest/boot-metrics. Times are seconds from kernel start to the process being spawned, read from /proc/<pid>/stat, so firmware and bootloader (~16.5 s here, identical for both) are not included. Medians of 3 runs; raw data in tests/livetest/dbox-bench-20260925.txt.

systemd (stock Fedora)schema-init (as installed)schema-init + host tuning+ schema-dbus
kwin_wayland spawned45.3 s45.5 s45.6 s38.4 s
plasmashell spawned64.3 s59.1 s58.9 s53.6 s
desktop drawn (desktop.so kioworker)92.2 s87.8 s84.0 s76.9 s
xdg-desktop-portal-kde spawned72.9 s60.1 s58.9 s53.6 s
RAM used at idle1477 MB1104 MB1036 MB1058 MB
processes219165163171
load average (1 min) at 4 min1.400.290.240.28
IO pressure (some avg300)11.48.57.77.7

With the stock D-Bus daemon, both inits reach the compositor at the same moment, because on a 5400 rpm disk that part is bound by reading files. schema-init's own gain comes after that: the shell and portal are up sooner, and the booted machine carries 373 MB less RAM and about 54 fewer processes. "Host tuning" is per-machine configuration, not part of schema-init: zswap off (zram only), noatime, Wi-Fi power save off, file indexer and unused autostart entries disabled, KWin blur/contrast off. It does not move the shell start, but it draws the desktop about 4 s sooner and trims another ~70 MB.

The last column (measured 2026-09-27, medians of 4 runs) adds schema's own message bus, schema-dbus, on both the system and session bus. To isolate it, the same box with the same tuning was measured with the switch rolled back to the stock daemon (3 runs: kwin 46.0 s, plasmashell 61.1 s, desktop 83.5 s, 1050 MB — matching the 09-25 column), then switched back. schema-dbus alone brings the compositor up 7.6 s sooner and draws the desktop 6.6 s sooner, at the same RAM; the gain lands before the compositor starts. Raw data in tests/livetest/dbox-bench-20260927.txt. End to end, the desktop is drawn about 15 s sooner than on stock systemd.

These are one laptop's numbers. The run-to-run spread was small (plasmashell 57.7–59.7 s on schema-init vs 63.5–64.4 s on systemd), but a different disk, CPU or desktop will give different figures.

PID 1 RSS — every measurement

One table, every number, each naming the machine, the build and the service count it came from. Anything not listed here is not a measurement we have.

PID 1RSSMachine / conditionsMeasured
schema-init1.2 MBminimal static boot, QEMU/KVM 512 MB / 2 vCPU2026-06-14
schema-init2.6 MBlive desktop, QEMU/KVM 512 MB / 2 vCPU2026-06-14
schema-init3.3–4.0 MBFedora 44, KDE Plasma + Docker/podman, 47 services, v0.1.0 — six consecutive boots2026-07-16 → 07-24
systemd20.1 MBFedora Cloud Base 44 clean idle, 15 running units, same kernel, same QEMU profile2026-06-14

On the same kernel and QEMU profile that is 8–17× lighter. The desktop and the Cloud Base figures are not a fair pair — one runs KDE, the other is headless — so they are not presented as one. The only apples-to-apples comparison here is minimal-boot schema-init vs clean-idle systemd.

Two numbers this README used to carry, and why they're gone:

  • "892 KB" was real, but it was an earlier and smaller build on the Dell. Current builds measure 1.2 MB minimal and 3.3–4.0 MB at a full desktop; the init has grown (timers, cpuset, cgroup delegation, container support). Leading with the lowest figure ever recorded, from a binary you can no longer download, isn't a footprint claim — it's cherry-picking. 892 KB was also never the binary's size on disk (see Binary size).
  • "40 MB – 120 MB" for systemd's PID 1 was never measured by this project. The measured figure is 20.1 MB, above.

Binary size

make produces an unstripped static binary. Measured on the v0.1.0 build (601b18ac, 2026-07-24):

Bytes
as built (static, with debug info)5,607,8405.6 MB
after strip schema-init1,199,1441.2 MB
.text alone1,119,0861.1 MB

.text alone is 1.1 MB, so no build of this binary has ever been under 1 MB on disk. Reproduce with ls -l, size and strip. Note that plain make does not strip — quote 5.6 MB for what you build yourself, 1.2 MB only for a stripped binary. make release produces the stripped set in release/ alongside a SHA256SUMS file; that is what release assets ship.

Architectural efficiency

Live measurements from a 9-hour uptime session (Fedora 44, KDE Plasma, GreyBox — note this node runs an older, smaller build; its RSS is not comparable to the current one):

Metricsystemdschema-initArchitectural elimination
Idle CPU consumptionConstant ambient timer wakeups~0.03ms/min (1.06s over 9h)CPU reaches deeper C-states — hardware idle, not just low-utilization idle
State trackingD-Bus event loops, logging daemonsDirect POSIX shared memory / binary flag probesRemoves IPC serialization and deserialization bottlenecks entirely
Session trackingutmp/logind infrastructureGhost sessions — who/w show 0 usersZero inode contention on /var/run/utmp; who and w are zero-overhead no-ops under concurrent logins

The load average on an idle system with schema-init as PID 1 sits at 0.03. On the same hardware with systemd, ambient timer wakeups hold it at 0.10–0.20 at idle. The difference is structural: schema-init's tick loop sleeps indefinitely once all services are stable. Nothing wakes it.

turbostat on Eli (Dell Inspiron 3542, Intel i3-4005U, Fedora 44, full Cinnamon desktop):

C10%: 92–99%    ← deepest available C-state; CPU hardware-verified
C6%:  0.00%     ← skipped; CPU goes straight to C10
Busy: 0.21–0.38%
PkgWatt: 1.23–1.32W   ← entire SoC including iGPU, read via Intel RAPL
GFX%rc6: 99.67%        ← integrated GPU in deepest sleep state

C10 is the deepest sleep state on Haswell silicon. Reaching it requires the CPU to sit undisturbed long enough to flush caches and power-gate internal voltage rails — typically blocked by the constant timer wakeups from systemd's watchdog, journal flush, and D-Bus polling infrastructure. At 92–99% C10 residency with a full desktop running, schema-init is generating near-zero ambient noise. The 1.25W package figure is read directly from Intel RAPL hardware energy counters, not estimated. Services with ready_path set promote the instant the path exists — no blind timer. stable_secs (default 10s) is the fallback. The remaining ~10s cluster is network/getty/sshd with no readiness path.


Runtime control

schema-ctl is a control client that communicates with the running init over a Unix domain socket at /run/schema-init.sock.

sudo schema-ctl status          # full state dump for all services
sudo schema-ctl status --json   # machine-parseable JSON — for supervisory loops and IEC 62304 audit
sudo schema-ctl status --kv     # flat key=value — grep-friendly
sudo schema-ctl status <name>   # one service: state, last exit, readiness, each hardening knob and its source
sudo schema-ctl timing          # per-service spawn→ready cost, boot critical path first
sudo schema-ctl analyze         # boot critical chain + waterfall; how each service proved ready (notify / bus-name / ready_path / stable timer / exit)
sudo schema-ctl analyze <name>  # the dependency chain that held <name> back
sudo schema-ctl list            # names and current states only
sudo schema-ctl start <name>    # start a stopped or EXCISED service
sudo schema-ctl stop <name>     # send SIGTERM to a running service
sudo schema-ctl restart <name>  # stop + re-queue through the state machine
sudo schema-ctl add <path>      # load a new .svc file at runtime, no reboot needed
sudo schema-ctl reload          # re-read the services directory (rejected if new config has a cycle)
sudo schema-ctl reload --evict  # reload + SIGTERM any running service no longer present in config
sudo schema-ctl pet <name>      # service heartbeat check-in — resets watchdog_timeout_ms window
sudo schema-ctl reset [<name>]  # reset restart/dormant counts and re-queue failed services
sudo schema-ctl reexec [<path>] # replace the PID 1 binary in place, services keep running
sudo schema-ctl reboot          # orderly shutdown sweep, then reboot (also: poweroff)

schema-ctl exits 1 when PID 1 refuses a command (err: reply), so scripts can check it. The socket is chmod 0600 — root only. Build alongside the init binary:

make schema-ctl
sudo cp schema-ctl /usr/local/bin/schema-ctl

Recovery console

When a Wayland compositor wedges, ctrl-alt-F2 only gets you another login on the same broken session, and systemd's rescue/emergency targets are all-or-nothing — they tear the session down and lose your work. schema-board on a dedicated VT is the alternative: a surface that sits below the compositor and shows you what is actually wrong.

schema-board --tty /dev/tty8                    # then ctrl-alt-F8 to look at it
schema-board --tty /dev/tty8 --interactive      # ...and fix something from there

--interactive adds a cockpit: ↑/↓ (or j/k) to select a service, enter to raise a card, y to apply, n to cancel. The card is chosen from the service's state — DORMANT gets reset, EXCISED gets start, anything else gets restart — and the confirm panel prints the exact command before it runs:

 ▸ restart frigate?
   will run: schema-ctl restart frigate
   [y] apply   [n] cancel

Browsing stays read-only. The board is a pure shared-memory reader until you press y, so it needs no root to watch and keeps working when the control socket is wedged. Only applying a card opens the socket, and that needs root. The board can only ever issue a command you could have typed yourself. ctrl-C always works — ISIG is left on deliberately.

Note that this lets anyone at the physical console restart a service. That is not a new privilege boundary — the shipped gettys autologin root on tty2 — but it is worth knowing before you enable it on a machine other people can walk up to.

It reads the shared-memory export rather than the control socket and depends on nothing graphical, so a frozen desktop, a wedged control socket, and a saturated D-Bus all leave the process working. Give it a VT no getty owns — services/ ships gettys on tty2–tty6, and tty1 is the display manager, so tty7 and up are free.

What it survives, and what it does not

✅ VT switching works on a graphical schema-init system. This was broken until 2026-07-26 and is now fixed in scripts/schema-logind.py. Confirmed on real hardware — NVIDIA, sddm-started KDE Wayland session — by a human looking at the screen, which is the only evidence that settles a question about what is visible.

The board keeps reading and updating while the compositor is wedgedYes — seq advanced 228430 → 228569 across a 30 s freeze with kwin_wayland in state T
You can see it while the compositor is healthyYes — ctrl-alt-F8 shows the console, ctrl-alt-F1 returns to a repainted desktop
You can see it while the compositor is wedgedYes — the board rendered, in colour, with kwin_wayland in state T

The wedged case is what the recovery console exists for, and it is the fail-safe that carries it. With the compositor SIGSTOPed it cannot answer PauseDeviceComplete, so every device ack goes missing. Releasing the VT anyway — rather than waiting for acks that will never arrive — is the only reason the switch completes:

VT release requested — pausing 10 device(s)
DROP_MASTER ok on fd=11
10 device ack(s) missing — releasing anyway
VT_RELDISP(1) — switch allowed to proceed

Blocking there would strand the kernel mid-switch with DRM master already dropped: a black screen with no way back. On SIGCONT the compositor flushes all ten acks and a late Seat.SwitchTo for the keypress it was frozen on, and the session recovers. Note that in the wedged case the kernel drives the handoff alone through VT_PROCESS — Seat.SwitchTo arrives after the release, not before it as in the healthy case. That is the difference a polling implementation cannot cover.

How the handoff works

A graphical session needs this chain, and every link now exists:

ctrl-alt-F<n>
  → kernel signals schema-logind (VT_PROCESS) and WAITS   ✅ VT_SETMODE at TakeControl
  → logind sends PauseDevice to the compositor            ✅ pause, per device taken
  → logind drops DRM master                               ✅ DRM_IOCTL_DROP_MASTER
  → logind acks with VT_RELDISP, kernel completes switch   ✅
  → fbcon restores the mode with master already free      ✅ console repaints
  → on return: VT_RELDISP(VT_ACKACQ), SET_MASTER, ResumeDevice

VT_PROCESS mediation is the load-bearing part, and a polling implementation cannot replace it. An earlier fix watched /sys/class/tty/tty0/active every 250 ms and implemented PauseDevice/ResumeDevice, VTNr, and Seat.SwitchTo — all necessary, none sufficient. The kernel completes a VT switch synchronously, and fbcon's mode restore runs during it, while master is still held; it fails silently and is never retried. Measured: after the poll dropped master the console sat at 15 non-black pixels, and a second switch — master already free — painted 32,771. The active-VT poll survives only as a fallback for when VT_SETMODE fails.

Two things this depends on, both worth knowing before you touch it:

  • ReleaseControl restores VT_AUTO. KWin calls it from ~LogindSession. Anything that takes control and exits — a display-manager greeter, for instance — tears mediation down for whoever comes next.
  • The chord arrives twice, once from the kernel's VT handler and once from the compositor calling Seat.SwitchTo for the same keypress. Handling it twice overwrites and leaks the pending-ack timer.

Two earlier revisions of this section were wrong in ways worth recording. The first blamed a stopped compositor for being unable to release DRM master; a healthy one does not release it either, because nothing asks. The second proposed having the board take DRM master itself, which cannot work — DRM_IOCTL_SET_MASTER fails while another process holds master. The real cause was that Properties.Get for VTNr failed, so KWin's LogindSession::create() bailed and it silently fell back to NoopSession, whose switchTo() is an empty function body.

On a machine with no graphical session — a server, a Pi, an initramfs-less boot before the display manager starts — the recovery console works regardless, because nothing has taken KMS.

If you are stranded on an invisible VT, sudo chvt 1 from any other shell (ssh included) puts you back.

To have PID 1 own it from boot, copy services/schema-board.svc.example into /etc/schema-init/services/:

name=schema-board
exec=/usr/bin/schema-board
args=--tty
args=/dev/tty8
needs_root=1
critical=0

Two things about that file are load-bearing:

  • args= is one argument per line. args=--tty /dev/tty8 on a single line passes one argv of "--tty /dev/tty8", which schema-board rejects. Repeat the key.
  • --tty is not optional for a service. Services are spawned with stdout redirected to /var/log/schema-init/<name>.log, so without --tty the board would faithfully paint its frames into a logfile.

On the console it takes over, the board disables screen blanking and hides the cursor, restoring the cursor when it exits.


Debugging

Service state

sudo schema-ctl status          # full dump: state, pid, restart count, weight
sudo schema-ctl list            # compact: name + state only
sudo schema-ctl timing          # kernel→PID1 handoff + per-service stable timestamps

A service stuck in NEW_PROCESS means its dependencies haven't stabilised. status shows the state of every dep — trace upward.

A service in FRICTION is in last-chance recovery. On the next failed F6 probe it enters DORMANT (exponential backoff) rather than going straight to EXCISED. Use sudo schema-ctl start <name> to manually re-queue it immediately instead of waiting out the backoff.

Service logs

tail -f /run/log/schema-init/<name>.log    # live stdout/stderr for a service
cat /run/log/schema-init/dbus.log          # full output since last boot

These are plain text on a tmpfs. If a service is failing silently, its output is here.

D-Bus tracing

If a desktop application hangs for exactly 25–30 seconds, D-Bus auto-activation is timing out trying to reach an unregistered interface. Trace it:

dbus-monitor --system 2>&1 | grep -A4 "method call"

The culprit will appear as a method call to a destination=org.freedesktop.SomeName that produces no method return for ~25 seconds.

Fix options:

  1. Register the interface — see schema-logind for the pattern
  2. Mask the activation file: sudo rm /usr/share/dbus-1/system-services/<name>.service

Rescue shell

If schema-init drops to a rescue shell at boot (cycle detected, or fatal probe failure), you have a minimal /bin/sh with access to the mounted filesystems. From there:

# inspect service files
ls /etc/schema-init/services/
cat /etc/schema-init/services/broken.svc

# fix and re-exec
vi /etc/schema-init/services/broken.svc
exec /sbin/schema-init

Crashes

Fedora's initrd points kernel.core_pattern at systemd-coredump, which hands each core to a socket-activated systemd-coredump.socket that does not exist under schema-init, so every crash is logged as "Failed to connect to coredump service" and the core is thrown away. At boot PID 1 replaces that pattern, or the kernel's default core when the initrd sets none, with schema-coredump (apport, abrt or a pattern you set by hand is left alone), then raises its soft RLIMIT_CORE to the hard limit so services inherit a limit the helper can honour.

Each crash gets a record in /var/lib/schema-coredump/ (root-only, 0700): core.<comm>.<uid>.<time>.<pid>.meta with the pid, signal, exe, command line, cgroup and schema-init service, and beside it a zstd core unless something says not to:

RESULTmeaning
storedcore kept
rlimitthe process ran with RLIMIT_CORE below 4 KiB; it opted out
rate-limitedthe same program already left a core in the last 60 s (crash loop)
no-spacethe filesystem is within 10% of full
truncatedthe core hit a cap: RLIMIT_CORE, 2 GiB uncompressed, or the space budget

Old cores are removed oldest-first once they total 4 GiB; 200 records are kept. One line per crash goes to syslog (/var/log/schema-init/journal.log).

sudo schema-coredump --list
sudo zstd -dc /var/lib/schema-coredump/core.foo.….zst > core && gdb /usr/bin/foo core

If something re-applies sysctl.d after boot (a sysctl --system service), it puts systemd-coredump back; run schema-coredump --take-pattern after it.


Logs

Init log — schema-init writes spawn/promote/death events to stdout, which the kernel connects to the console at boot. To persist:

exec /sbin/schema-init >/var/log/schema-init.log 2>&1

Per-service logs — each service's stdout and stderr are captured automatically to:

/var/log/schema-init/<name>.log        # preferred (persists across boots)
/run/log/schema-init/<name>.log        # fallback when /var is not writable (tmpfs, per-boot)

To read them while the system is running:

tail -f /var/log/schema-init/dbus.log
tail -f /var/log/schema-init/network-manager.log

There is no journal daemon. Logs are plain text, always.

Rotation — schema-init.logrotate is installed to /etc/logrotate.d/schema-init (daily, or sooner at 100 MB, keeping 4 compressed generations). It covers /var/log/schema-init/*.log and the KDE deploy's /var/log/sddm-schema.log, which sddm-logged writes under set -x. It uses copytruncate, and that is not optional:

  • A service's log fd is opened in the child before exec (service.c:321) and held for the whole life of the process. Renaming the file would leave every running service appending to the old inode — the new file would stay empty until the service restarted.
  • SIGHUP to PID 1 means reload configuration (init.c:1312), not "reopen logs". A postrotate kill -HUP 1 would silently trigger a config reload instead of rotating.
  • Every writer uses O_APPEND (service.c:321, schema-journal-sink.c:344), which is what makes truncation safe: writes resume at offset 0 instead of leaving a sparse hole at the old offset.

Something has to run logrotate. There is no cron and no logrotate.timer here, so schedule it as an ordinary wall-clock timer — see services/logrotate.svc.example:

name=logrotate
exec=/usr/sbin/logrotate
args=/etc/logrotate.conf
needs_root=1
on_calendar=00:10
persistent=1

Copy it into your service directory to arm it:

sudo cp /usr/share/schema-init/services/logrotate.svc.example \
        /etc/schema-init/services/logrotate.svc
sudo schema-ctl reload

persistent=1 matters here: a box that is powered off at 00:10 would otherwise skip that day's rotation entirely and only catch up the next night.

Without that timer (or some other caller) the config sits inert and logs still grow unbounded. maxsize 100M is not a safety net on its own — it is only consulted when logrotate actually runs.

journalctl shim (optional Track B) — software and post-install scripts that shell out to journalctl -u <svc> would fail with no journald present. scripts/journalctl is a drop-in interceptor: install it to /usr/local/bin/journalctl and it serves the matching *.log from the directories above, swallows unknown flags, supports -o json, and always exits 0 so a caller piping it to jq/awk never hard-crashes. It does not read a binary journal — there isn't one.


Shared memory interface

Running processes can read service state via POSIX shared memory at /schema-init:

#include "schema_shm.h"

int fd = shm_open("/schema-init", O_RDONLY, 0);
schema_shm_t *shm = mmap(NULL, sizeof(schema_shm_t), PROT_READ, MAP_SHARED, fd, 0);

for (int i = 0; i < shm->count; i++) {
    printf("%s state=%d weight=%d pid=%d\n",
           shm->svc[i].name,
           shm->svc[i].state,
           shm->svc[i].weight,
           shm->svc[i].child_pid);
}

D-Bus compatibility

On a no-systemd desktop, several interfaces are missing that desktop environments expect. schema-logind (distros/*/services/schema-logind.svc) handles the session/power/host interfaces in a single Python process on the system bus. The org.freedesktop.systemd1 management surface is served by its own process — see below.

InterfaceWhy it mattersWhat schema-logind returns
org.freedesktop.login1Power/reboot buttons, session tracking, polkit seat queriesPowerOff, Reboot, CanPowerOff, CanReboot, Inhibit, GetSessionByPID, mock Session/User/Seat objects
org.freedesktop.ConsoleKitCinnamon session manager uses ConsoleKit, not logind, for CanRestart/CanStop — controls restart button visibilityGetSessionForUnixProcess, CanRestart → True, CanStop → True, Restart/Stop → SIGINT/SIGTERM to PID 1
org.freedesktop.hostname1About This System panel, network-manager displayhostname, static hostname, OS pretty name, hardware vendor/model from /sys/class/dmi/
org.freedesktop.systemd1systemctl, Cockpit's Services page, and KDE/GNOME unit-state queries — the full systemd-compat management surfaceLive per-unit ActiveState/SubState/MainPID/NRestarts mapped from schema-ctl; ListUnits/ListUnitsFiltered, GetUnit, GetUnitFileState, StartUnit/StopUnit/RestartUnit driving schema-ctl for real; PropertiesChanged on state transitions. Served by a separate schema-systemd1 process (see below)
org.freedesktop.timedate1Date & Time settings panel: timezone, NTP status, clockTimezone (from /etc/localtime), CanNTP/NTP/NTPSynchronized → true, TimeUSec; SetTimezone re-links /etc/localtime and writes /etc/timezone for real

Without these stubs, KDE and GNOME panels hit the D-Bus default timeout (25–30s) before giving up. With them, the same queries return in <100ms.

D-Bus policy required. The systemd-shipped org.freedesktop.login1.conf policy denies all non-root calls to login1 by default — KDE and Cinnamon will never see the power buttons without a drop-in. Install the one from this repo:

sudo cp distros/shared/dbus/schema-logind.conf /etc/dbus-1/system.d/schema-logind.conf
sudo dbus-send --system --type=method_call --dest=org.freedesktop.DBus \
    /org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig

Then log out and back in (or reboot). The policy whitelists CanPowerOff, CanReboot, PowerOff, Reboot, and all session/seat methods schema-logind exports.

schema-logind is not a dependency of schema-init itself — it is a userspace service like any other. Drop its .svc file in your services directory and list it as a dep of your display manager:

name=sddm
exec=/usr/sbin/sddm
dep=dbus
dep=schema-logind
dep=polkitd
needs_root=1

The real systemd1 surface (schema-systemd1). Unlike the read-only stubs above, org.freedesktop.systemd1 is served by its own process (services/schema-systemd1.svc → scripts/schema-systemd1.py), not schema-logind. It registers every schema-init unit as a …/unit/<name>_2eservice object and mirrors live state from schema-ctl, so systemctl status <svc> and Cockpit's Services page show real ActiveState/SubState/MainPID/restart counts — and StartUnit/StopUnit/RestartUnit drive schema-ctl for real. Unit names are validated before being handed to schema-ctl (argv/newline injection guard). State transitions emit PropertiesChanged, so Cockpit updates without a refresh. Design notes: docs/superpowers/specs/2026-06-20-schema-systemd1-dbus-design.md.

The sd_booted() signal. mount_pseudo() creates /run/systemd/system at early boot (init.c). libsystemd's sd_booted() is a bare access() on that path, so any software gated on "is systemd the init?" — KService/ksycoca, elogind clients — gets a positive answer with no shim. This is what made the old LD_PRELOAD mock_sd.so workaround (which faked the check to stop KDE's ksycoca from spinning at idle) unnecessary: the signal is now native and costs one mkdir.

The sd_login_monitor directories. libsystemd's sd_login_monitor_new(NULL, …) — used by WirePlumber's logind module and other session/seat-aware clients — sets an inotify watch on /run/systemd/{sessions,seats,users,machines}. If any of those directories is missing the call fails with -ENOENT and the client silently drops logind integration (for WirePlumber that means no device reservation, no session-based pause). schema-logind creates all four at startup, empty — matching what real logind does even with no active sessions — so those clients initialize cleanly. Costs four mkdirs.

The system bus itself (schema-dbus)

Everything above runs on the D-Bus system bus. schema-dbus (schema-dbus.c) is that bus — a native C implementation of the message broker that replaces dbus-daemon/dbus-broker as the process owning /run/dbus/system_bus_socket. It does the real work of a bus daemon: EXTERNAL authentication, well-known and unique name ownership, client→client routing with reply tracking, match-rule signal delivery, the org.freedesktop.DBus driver, and unix-fd passing — libdbus can't demarshal fd-carrying messages, so the broker parses the wire format directly and forwards the fds opaquely (sdbus_wire.h).

It enforces the same policy as the stock daemon. The system's shipped busconfig XML (/usr/share/dbus-1/system.conf and its system.d/ drop-ins) is dissolved into a flat allow/deny table at startup by scripts/schema-dbus-run.sh, which then execs the broker against it — so <deny>/<allow> rules for login1, polkit, NetworkManager and the rest are honored exactly. A conformance test checks the dissolved verdicts against a frozen corpus (tests/test_sdbus_conformance.c).

Activate it the way you'd flip udev — advanced, opt-in, reversible:

sudo make install-dbus-sp1                                    # installs broker + launcher, changes nothing yet
cp services/dbus.svc.sp1 /etc/schema-init/services/dbus.svc   # the flip
sudo reboot

The installer ISO and the migration wizard both offer this flip as an optional step after the udev cutover, in its own reboot, with a seatbelt health check that rolls back to stock dbus-daemon automatically if the bus doesn't come up (shipped since v0.4.1, hardware-tested on a real install). The manual steps above are for source builds.

Build needs dbus-devel (make schema-dbus). If the policy dissolve ever fails at boot, the launcher self-heals to stock dbus-daemon on the spot, so even a broken flip still comes up on a working bus; to roll back permanently, restore the stock dbus.svc (exec=/usr/bin/dbus-daemon, args=--system, args=--nofork) and reboot.

Status. Proven serving a full KDE Plasma desktop as the live system bus across reboots — kwin, plasmashell, polkit, PowerDevil, portals, WirePlumber, tailscale and schema-logind all routing through it. The driver interface is complete: on-demand service activation (implicit and StartServiceByName), ReloadConfig, UpdateActivationEnvironment (session bus), ListQueuedOwners, introspection and the driver properties, and BecomeMonitor, so busctl monitor, dbus-monitor, busctl status and gdbus introspect all work against it. Name ownership honors the policy's own rules exactly like dbus-daemon. Validate in schema-vmtest before flipping hardware.

The session bus, too. The same broker also runs the per-user session bus ($XDG_RUNTIME_DIR/bus). scripts/schema-dbus-session-run.sh is a drop-in for plasma-dbus-run-session-if-needed: it starts schema-dbus without --system (which is what selects session mode, with session.conf's allow-all policy), exports DBUS_SESSION_BUS_ADDRESS, and runs the session command as its child. If the broker won't come up, it falls back to stock dbus-daemon --session. A watchdog tears the broker down when the session exits, and ends the session if the broker dies mid-session, so the autologin loop respawns it cleanly instead of leaving a desktop with no bus. The fedora-kde autologin uses it by default. Proven live on a KDE Plasma desktop — kwin, plasmashell, Dolphin, Konsole, portals, the tray.


Running packaged software

The D-Bus surface above lets tools manage schema-init units as though systemd were running. The other half of the compatibility story is install-time: an RPM or deb scriptlet that runs systemctl enable foo or systemctl daemon-reload must not error out on a schema-init box, and the foo.service it just dropped into /usr/lib/systemd/system has to become something schema-init can actually run. Two pieces cover this — a translator, not an emulator.

The shim (schema-systemctl). A systemctl(1) drop-in that packaging swaps in for /usr/bin/systemctl (via alternatives + a symlink). It honours systemd's verb and exit-code contract so scriptlets succeed: lifecycle verbs (start/stop/restart/status/is-active/is-enabled) drive schema-ctl for real, enable/preset record enable-intent to /var/lib/schema-init/pending.list (deduplicated) — the hand-off to the importer — and daemon-reload and the other no-op-here verbs exit 0 so nothing a caller pipes into hard-crashes. All logic lives in systemctl_shim.h (header-carried, static-inline; schema-systemctl.c is a thin entry point). Packaging is idempotent and reversible: %post saves the stock binary as systemctl.real, %postun restores exactly that — and on a box that never had systemd, removes the now-dangling shim symlink instead. Verified end-to-end through a real dnf install/dnf remove round-trip in a fedora:44 container.

The importer (schema-import). distros/fedora-installer/migrate/schema-import.py drains pending.list and translates each queued .service into a native .svc on 80/20 field coverage: ExecStart → exec=/args= (with $VAR/${VAR} resolved against the unit's own Environment=, since schema-init exec()s with no shell), Type=oneshot → oneshot=1, Type=notify/notify-reload → notify=1, Type=dbus + BusName= → ready_bus_name=, systemd's Restart=no default → no_restart=1, User= → user= (else needs_root=1), and Environment= → env= — a .svc key applied via setenv in the child before execv. Type=forking with an absolute PIDFile= → pid_file=. Known ratholes are logged and skipped, never half-translated: Type=forking without one, Type=dbus without BusName=, template units (foo@), and units with no usable ExecStart. Each skipped unit leaves <name>.svc.skipped beside the generated .svc files — the reason and the original unit, commented out — so it can be written by hand; PID 1 loads only *.svc, so the note is inert. A unit whose file isn't found is left queued; the drain is idempotent and rewrites the queue atomically (--dry-run/--force available). Design notes: docs/superpowers/specs/2026-09-14-schema-systemctl-shim-design.md.

Together this is the deployability turn: reclamation stops being "rewrite each daemon by hand" and becomes "install whatever ships, and import its units."


Porting to a new distro

Starting from scratch on a distro not in distros/:

1. Build the binary on the target (or cross-compile):

git clone https://github.com/ajax80/schema-init
cd schema-init && make

2. Install:

sudo cp schema-init /sbin/schema-init
sudo cp schema-ctl  /usr/local/bin/schema-ctl
sudo mkdir -p /etc/schema-init/services

3. Write service files. Start minimal — just enough to reach a console:

# /etc/schema-init/services/udevd.svc
name=udevd
exec=/usr/lib/systemd/udevd
args=--daemon
needs_root=1
stable_secs=3

# /etc/schema-init/services/dbus.svc
name=dbus
exec=/usr/bin/dbus-daemon
args=--system
args=--nofork
needs_root=1
stable_secs=2
ready_path=/run/dbus/system_bus_socket

The udevd path varies by distro: /usr/lib/systemd/udevd (Fedora/Debian), /lib/udev/udevd (older Debian), /usr/bin/udevd (Arch).

4. Configure GRUB (see Building → GRUB setup above). Boot with a fallback entry pointing at systemd so you can recover.

5. Boot and check:

sudo schema-ctl list       # all services should reach FUNDAMENTAL
sudo schema-ctl timing     # see where time goes
tail /run/log/schema-init/udevd.log   # if something is EXCISED, check its log

6. Add services incrementally. Bring up network, then login manager, then display manager. Add dep= links to enforce order. Add ready_path= for anything with a socket or pidfile.

7. Handle D-Bus hangs. Open your desktop's settings panel immediately after first login. If it hangs >5s, run dbus-monitor --system and identify the missing interface. Add a stub to schema-logind or mask the activation file.

Common issues by distro:

IssueCauseFix
udevd not populating /dev/inputudev not settled before display managerdep=udev in display manager svc; udevadm settle in a oneshot before it
polkit "not authorized" on NMpolkit rule missing wheel groupCopy distros/fedora-kde/config/polkit/10-schema-nm.rules
/etc/resolv.conf is a dead symlinksystemd-resolved wrote itrm /etc/resolv.conf && echo "nameserver 1.1.1.1" > /etc/resolv.conf in your network oneshot
Plasma/GNOME hangs on settings openMissing D-Bus interfaceSee D-Bus compatibility section above
PipeWire/PulseAudio not startingsystemd user session missingAdd autostart .desktop entry, or run from display manager wrapper script
display manager exits immediatelyNo seat availableEnsure elogind or schema-logind is up and answering login1 before display manager starts
X11/XWayland apps die with Unable to open display (Steam, any non-Wayland-native app)systemd-tmpfiles normally creates /tmp/.X11-unix as 1777 root:root; with no systemd it's missing or wrong-perm, and an X server refuses a /tmp/.X11-unix without the sticky bit — so the compositor's XWayland silently never starts and DISPLAY is never exportedoneshot before the display manager: mkdir -p /tmp/.X11-unix && chown root:root /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix (also clear stale /tmp/.X[0-9]*-lock). dep= it from the DM. On a root-fs /tmp (not tmpfs) the broken dir persists across reboots, so this isn't self-healing
flatpak/snap apps won't launch — The name org.<app>.desktop was not provided by any .service filesThe session D-Bus bus computes its .service search dirs once at startup from XDG_DATA_DIRS; with no systemd user env-generator that variable is unset when the bus is born, so it never scans …/flatpak/exports/share/dbus-1/services. Anything later (a plasma-workspace/env script) runs inside the bus's child — too lateExport XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share:/var/lib/snapd/desktop in the env that launches the session bus, before the bus starts. Stopgap without re-login: symlink the *.service files into ~/.local/share/dbus-1/services/ (always searched regardless of XDG_DATA_DIRS) and ReloadConfig the bus

Distributions

Working configurations for specific distros and desktops live in distros/.

Fedora 44 + KDE Plasma (distros/fedora-kde/)

Full KDE Plasma 6 desktop on Fedora 44 with schema-init as PID 1. Boots from a btrfs subvolume alongside a normal Fedora install — no repartitioning required.

What's running:

ServiceRole
udevdDevice enumeration — required for libinput and /dev/input/event*
dbusSystem bus
network-upLoads r8152 USB ethernet module, udev settle
network-managerOwns the network interface via NM profile
polkitdAuthorization — required for NM actions
schema-logindMinimal org.freedesktop.login1 D-Bus stub — restores KDE shutdown/restart buttons
sddmDisplay manager (via sddm-logged wrapper, no systemd session)
sound-modulesoneshot — loads AMD Ryzen audio modules at boot
bluetoothdStarts bluez daemon — registers org.bluez, restores KDE Bluetooth applet
zram-swaponeshot — zstd-compressed zram swap device; replaces systemd's zram-generator

See distros/fedora-kde/README.md for full installation instructions and key fixes.

Raspberry Pi Zero W (distros/raspberry-pi-zero-w/)

WiFi headless deploy on a Pi Zero W (BCM2835, armv6l, 32-bit ARM). No Ethernet, no HDMI — schema-init as PID 1, WiFi up, SSH accessible in ~50 seconds from cold boot. First ARM bare-metal target.

Service chain:

ServiceRole
udevDevice enumeration daemon
udev-triggerOneshot — coldplug trigger + settle; loads brcmfmac WiFi firmware
dbusSystem bus — mandatory for Pi OS wpa_supplicant
wpa-supplicantWiFi association (config-file mode, not D-Bus mode)
dhcpcdDHCP client, foreground (-B), wlan0 only
sshdFirst usable interface — up when DHCP lease is held

See distros/raspberry-pi-zero-w/README.md for the full list of gotchas (rfkill country code, dbus privilege drop, coldplug trigger, dhcpcd forking behavior) and installation steps.


Roadmap

  • Runtime service loading — schema-ctl add <path> loads a new service at runtime
  • Runtime reload + removal — schema-ctl reload [--evict] re-reads config (cycle-checked); --evict SIGTERMs services dropped from config, no reboot
  • login1 D-Bus stub — schema-logind restores KDE shutdown/restart buttons on no-systemd systems
  • event-driven main loop — signalfd for SIGCHLD + poll() with 250ms timeout; wakes on child death and ctl commands instead of busy-polling
  • Boot hang fix — dep_idx alignment bug in group dep resolution; poll() replaces epoll (PID 1 epoll deadlock on kernel 6.1.0-49)
  • Boot timing — schema-ctl timing reports kernel→PID1 handoff and per-service FUNDAMENTAL/PERFECT timestamps (CLOCK_MONOTONIC)
  • Boot time measurement — 29.5s → 20.7s with ready_path probes; stable_secs fallback per service
  • Per-service readiness probes — ready_path= promotes on path existence; stable_secs= fallback timer
  • Cgroup assignment race fix — pipe barrier guarantees cgroup.procs written before child exec
  • Dynamic poll timeout — loop sleeps indefinitely once all services stable; 0% CPU idle
  • Service log files — stdout/stderr per service at /run/log/schema-init/<name>.log
  • D-Bus stubs — hostname1 and systemd1 Manager stubs in schema-logind; KDE Settings 25s → 2s
  • Fedora KDE distribution — GreyBox daily driver, full KDE Plasma 6 on Fedora 44
  • Fedora Cinnamon distribution — Eli (Dell Inspiron), keyboard/touchpad/ethernet working
  • STATE_DORMANT (75) — exponential backoff before 76 verdict; critical services never excise
  • Soft dep cascades — non-critical EXCISED deps skipped; dependents proceed without them
  • aarch64 cross-compile — make aarch64; all three binaries static; Ungulate Leg target ready
  • ARM bare-metal deploy — Pi Zero W (armv6l), Pi OS Trixie; SSH up in ~50s from cold boot
  • schema-desktop — SDL2 live service viewer; make desktop + autostart entry in Cinnamon and KDE distros
  • Dead Man Token hardware watchdog — /dev/watchdog driven by per-service check-in via schema-ctl pet; any critical service missing its watchdog_timeout_ms window stops WDT petting → hardware reboot; PID 1 deadlock covered implicitly
  • Symlink template instances — motor@12.svc → motor@.svc; $INSTANCE injected at spawn; $SLOT_ID fallback for GPIO-strapped nodes; one SD card image per fleet
  • Structured telemetry — schema-ctl status --json and --kv for machine-parseable supervisory loop consumption and IEC 62304 audit traceability
  • Cgroup resource limits — cpu_limit= (1–100, % of one core), mem_limit= (MB), cpuset= (CPU affinity / core pinning, systemd AllowedCPUs= analog), and cpuset_partition= (isolated/root exclusive cores via cgroupv2 partitions — dynamic isolcpus) per .svc; written via sync-pipe window before child exec; IEC 62304 Class C blast-radius isolation
  • zram swap — zram-swap.svc boots a zstd-compressed zram swap device, replacing systemd's zram-generator; eliminates disk thrashing / periodic stutter under memory pressure
  • schema-udev — native .dev-rule device manager with an authoritative cutover that retires systemd-udevd and owns /dev: node creation, the /dev/disk/by-* + /dev/char + /dev/block symlink farm, uaccess seat ACLs, the libudev monitor broadcast, and the /run/udev/data database — checksum-backed, reversible, guarded by a headless seatbelt
  • schema-journal-sink — a journald-shaped endpoint (schema-journal-sink.c) that drains /dev/log to a plain per-service logfile with a journalctl shim; no journal database
  • schema-logind consolidation — one schema-logind process now serves login1 and the hostname1 / timedate1 / locale1 surfaces (sessions, seats, power, hostname, RTC/timezone, locale) — the "little-4" in a single readable program
  • schema-systemd1 — the full org.freedesktop.systemd1 management surface (ListUnits, GetUnit, Start/Stop/RestartUnit, per-unit ActiveState/SubState/MainPID/NRestarts, PropertiesChanged) served by its own schema-systemd1 process driving schema-ctl; systemctl status and Cockpit's Services page work
  • Built-in .svc timers — on_calendar= scheduling parsed in service.c retires cron and systemd .timer units (richer calendar forms tracked in docs/timers-design.md)
  • In-place migrator + COPR — schema-migrate --discover/--deploy/--uninstall converts a live Fedora KDE box to schema-init as PID 1 and back (proven end-to-end in a VM); prebuilt via dnf copr enable ajax80/schema-init (three packages), with a guided schema-init-wizard GUI
  • schema-dbus — native C broker replacing dbus-daemon on both the system bus and the session bus; enforces the dissolved busconfig policy (conformance-tested), does auth / name ownership / routing / match rules / unix-fd passing; serves a full KDE desktop as the live bus through reboots; opt-in, self-healing, reversible flip (on-demand activation, BecomeMonitor, full driver interface)
  • systemctl compat translator — a schema-systemctl shim (systemctl_shim.h) that makes packaged RPM/deb scriptlets (systemctl enable/daemon-reload/…) succeed on a schema-init box and records enable-intent to pending.list, plus a schema-import runtime importer that drains the queue and translates .service units into native .svc (ExecStart/Type/Restart/User/Environment with $VAR resolution, Type=notify → notify=1, Type=dbus → ready_bus_name=; Type=forking + PIDFile= → pid_file=; templates logged-and-skipped); dnf install/remove round-trip verified, generated units load under the real .svc parser
  • modules-load.d — PID 1 loads /etc/modules-load.d/*.conf at boot (systemd-modules-load parity)
  • schema-doctor — health checks with self-heal (boot-entry integrity, NM profiles bound to missing interfaces, session agents, powerdevil / ksycoca loops, panel pins), run at boot and periodically
  • Boot snapshots + GRUB fallback — known-good root snapshots with their own boot entries (separate /boot, initramfs and non-fstab /home handled) and a boot-success guard
  • PID 1 re-exec — schema-ctl reexec swaps the init binary in place with a dry run, sealed state handoff and rollback; RPM upgrades re-exec automatically, so updating PID 1 is just dnf upgrade, no reboot
  • Service watchdog — watchdog_sec= with WATCHDOG=1 over sd_notify (systemd WatchdogSec= parity): missed window → SIGABRT + core, then SIGKILL, then the normal restart arc
  • Fedora 44 installer ISO, verified on real hardware — netinst + kickstart installs KDE on schema-init as PID 1 with an optional guided schema-udev flip; clean install to desktop on a Dell i3 laptop with no hand fixes (v0.3.0)

Origin

schema-init was built by Jonathan Ayers in 2026 as the operating layer for the Ungulate Leg — an exoskeleton controller that needs a deterministic, schema-driven PID 1 carrying none of systemd's assumptions about what a computer is for. (A sister project, the Samara Wing, applies the same architecture to flight.) The reference hardware that proved it — a pawn-shop Dell Inspiron named Eli — booted to a full desktop under schema-init on May 30th, 2026. It was written alongside Claire, an AI (Claude, by Anthropic) that held continuity across the work.


License

AGPL-3.0-or-later for open source use — full text in LICENSE.
Commercial license available for embedding in proprietary systems — open an issue or contact via GitHub.


Built by Jonathan Ayers. The schema was written before the code.

aarch64
c
cgroups
dbus
embedded-linux
init
init-system
lightweight
linux
logind
no-systemd
pid1
process-supervisor
service-manager
state-machine
systemd-alternative
systemd-replacement
udev