pave

pave: converge a machine onto goal files written in bash.

commit 724088718266312bbfc1e36c5afa9e8860a22db6 · parent b131e7c978 · browse this tree
Ximianth, 2026-10-17 20:31:19 UTC

`pave run GOALS_DIR PHASE` sources PHASE.bash, then hosts/$HOSTNAME/PHASE.bash.
Phases are a fixed list (boot, session, login, manual), each tied to the
triggers pave knows how to install; a goal file not named for a phase is an
error, so a typo can't silently never run.

Failure semantics, and why they're shaped this way:

- Top-level commands run without errexit, under an ERR trap: each failure is
  recorded with file:line and the file carries on.  Unrelated goals shouldn't
  block each other.
- `( chain NAME ... )` switches errexit on for one subshell, so things that
  must happen together stop together.  chain refuses to run outside a
  subshell, since it would otherwise turn errexit on for the whole file.
- A failure inside a subshell also surfaces as a failed subshell in every
  enclosing shell.  A tally file (counts survive subshells; variables don't)
  lets each shell see that a deeper one already recorded it, so each failure
  is reported exactly once, at the deepest level.
- `source` returns the status of a file's last command, which the trap has
  already recorded; the trap recognizes that case by FUNCNAME and skips it.
- Goal files and chains must never run in a condition context (`x || y`,
  `if x`): bash ignores `set -e` inside those, which would silently disable
  every chain.  Comments mark the two places this matters.
- Each goal file runs in its own subshell, so an unset variable or `exit`
  aborts only that file.  Files with syntax errors don't run at all
  (checked with `bash -n` first), rather than running up to the error.

Primitives (ensure_file, ensure_link, ensure_block, write_knob) never rely
on errexit internally, so they fail correctly in chains, outside them, and in
conditions.  Their writes are temp + rename.

`pave install GOALS_DIR`, run as the user, installs the XDG autostart and
login-profile triggers, then sudo's itself into `install-boot`, which picks one
boot hook (systemd, OpenRC local.d, rc.local, cron.d, in that order of
certainty), installs it, and runs the boot phase immediately.

Reports go to /run/pave for root (emptied at boot, so a missing boot.report
means no trigger fired) and the XDG state dir for users (always exists, unlike
XDG_RUNTIME_DIR on machines without logind).  A converged run prints nothing.

Tests drive the real bin/pave against scratch goal dirs, with a fake root
(PAVE_SYSROOT) and a pass-through fake sudo for install.  Every assertion group
was watched failing against a deliberate sabotage of the logic it covers.
Also checked by hand: a root run against the real lru_gen sysfs files,
writing their current values back (no change reported, report in /run/pave).
diff --git a/Makefile b/Makefile
index 2fd6d68..bed0320 100644
--- a/Makefile
+++ b/Makefile
@@ -5,8 +5,11 @@ SHELL  = /bin/bash
 
 MADE ?= $(HOME)/made
 
+# shfmt finds the .bash files in these dirs by itself, but not the .test files.
+SOURCES = bin lib tests tests/*.test
+
 .PHONY: all
-all: fmt build test ## Everything that's a must before committing: fmt, build, test.
+all: fmt test ## Everything that's a must before committing: fmt, test.
 
 .PHONY: help
 help: ## Print info about all commands
@@ -16,29 +19,24 @@ help: ## Print info about all commands
 		| awk 'BEGIN {FS = ":.*?## "}; {printf "    \033[01;32m%-20s\033[0m %s\n", $$1, $$2}'
 
 .PHONY: setup
-setup: ## One-time dev env setup: activate git hooks (and install deps, if any).
+setup: ## One-time dev env setup: activate git hooks.
 	git config core.hooksPath hack/git-hooks
 
 .PHONY: fmt
 fmt: ## Format source in place.
-	$(error PLACEHOLDER: formatter invocation)
+	shfmt -w $(SOURCES)
 
 .PHONY: check
 check: ## Verify-only and fast; run by the pre-commit hook on every commit.
-	$(error PLACEHOLDER: format check in verify mode, plus other fast verifications at this project's discretion)
-
-.PHONY: build
-build: ## Build (dev profile).
-	$(error PLACEHOLDER: build invocation)
+	shfmt -d $(SOURCES)
+	shellcheck -x -S warning bin/pave lib/*.bash tests/*.test tests/testlib.bash
 
 .PHONY: test
 test: ## Run all tests.
-	$(error PLACEHOLDER: test invocation)
-
-.PHONY: clean
-clean: ## Remove regenerable outputs.
-	$(error PLACEHOLDER: removals)
+	for t in tests/*.test; do "$$t" || exit; done
 
 .PHONY: install
-install: ## Install binaries into $(MADE)/PLACEHOLDER/bin/.
-	$(error PLACEHOLDER: build or copy outputs into "$(MADE)/PLACEHOLDER/bin/")
+install: ## Install into $(MADE)/pave/ (bin/ and lib/).
+	rm -rf "$(MADE)/pave"
+	mkdir -p "$(MADE)/pave"
+	cp -R bin lib "$(MADE)/pave/"
diff --git a/README.md b/README.md
index ced3572..43ca346 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,191 @@
-PLACEHOLDER
-=========
+pave
+====
+
+pave converges a machine onto goals written in plain bash.
+
+Goals live in a goals directory (usually its own repo), sorted by **phase** -- when they run, and as whom -- and optionally by **hostname**.
+Triggers at boot, graphical session start, and shell login all call the same thing:
+
+```
+pave run GOALS_DIR PHASE
+```
+
+Goals are expected to be idempotent: running them again on a converged machine changes nothing, and says nothing.
+That's what makes it fine to run them often, from several triggers, and by hand.
+
+pave needs bash (4.4 or later) and coreutils.  It's Linux-first.
+
+
+Quick start
+-----------
+
+```
+git clone ... pave && make -C pave install    # into ~/made/pave/; or run pave/bin/pave in place
+
+mkdir -p ~/goals
+cat > ~/goals/boot.bash <<'EOF'
+( chain lru_gen
+	write_knob /sys/kernel/mm/lru_gen/enabled y
+	write_knob /sys/kernel/mm/lru_gen/min_ttl_ms 1000
+)
+EOF
+
+pave install ~/goals
+```
+
+`pave install` sets up every trigger this machine supports, asks sudo for the root-owned one, and runs the boot phase right away.
+
+
+The goals directory
+-------------------
+
+```
+GOALS_DIR/
+  boot.bash
+  session.bash
+  login.bash
+  manual.bash
+  hosts/
+    HOSTNAME/
+      boot.bash
+      session.bash
+```
+
+`pave run GOALS_DIR PHASE` runs `PHASE.bash`, then `hosts/$HOSTNAME/PHASE.bash`, each if it exists.
+Every file is optional.
+The hostname is matched literally, as bash's `$HOSTNAME` reports it.
+Any `*.bash` file in the top dir or a host dir that isn't named for a phase is an error, so a misspelled phase can't silently never run.
+Other files and directories (helpers, config files to install) are yours to arrange.
+
+### phases
+
+| phase | runs as | triggered by |
+| --- | --- | --- |
+| `boot` | root | once per boot: a systemd unit, an OpenRC `local.d` script, `/etc/rc.local`, or a cron `@reboot` line |
+| `session` | you | graphical session start: an XDG autostart entry |
+| `login` | you | login shells: a block in `~/.bash_profile`, `~/.bash_login`, or `~/.profile` (whichever bash reads) |
+| `manual` | either | nothing; it runs when you run it |
+
+Any phase can also be run by hand at any time.
+
+
+Writing goals
+-------------
+
+Goal files are bash, sourced by pave, with the goals dir as the working directory and an empty stdin.
+They run under `set -uo pipefail`, but **not** `set -e`.
+
+### every top-level command stands alone
+
+A failing command is recorded, with its file and line, and the file carries on with the next one.
+Independent things need no ceremony:
+
+```bash
+xinput set-prop 'SynPS/2 Synaptics TouchPad' 'libinput Accel Speed' 0.4
+ensure_file "$HOME/.config/git/ignore" 644 "$(<files/gitignore)"
+```
+
+### chains: things that must happen together
+
+When later steps only make sense if earlier ones worked, put them in a chain:
+
+```bash
+( chain dotfiles
+	test -d "$HOME/src/dotfiles/.git"
+	ensure_link src/dotfiles/vimrc "$HOME/.vimrc"
+	ensure_link src/dotfiles/tmux.conf "$HOME/.tmux.conf"
+)
+```
+
+Here, if the dotfiles checkout isn't there, no links get pointed at nothing.
+
+A chain is a parenthesized group whose first command is `chain NAME`.
+It stops at its first failing command -- including one inside a function it calls -- and that counts as one failure.
+The file carries on after it.
+
+A failed chain doesn't undo anything; the next run picks up where it stopped.
+
+Two things that defeat a chain:
+
+- `chain` outside of parentheses is refused (it would otherwise switch on `set -e` for the rest of the file).
+- A chain used as a condition (`if ( chain ... )`, or `( chain ... ) || something`) doesn't stop at failures.
+  That's bash: `set -e` is ignored inside anything being tested as a condition.
+
+### whole-file aborts
+
+An unset variable (or an `exit`) ends the file; it's recorded, and the host file still runs.
+A file with a syntax error doesn't run at all.
+
+### primitives
+
+Each reports a change only when it made one, and refuses to replace anything that isn't the kind of thing it manages.
+All paths must be absolute.
+
+- `ensure_file PATH MODE CONTENT` -- whole-file content (plus one trailing newline) and octal mode.
+  Parent dirs are created.
+  `CONTENT` from a file in the goals dir: `"$(<files/thing.conf)"`.
+- `ensure_link TARGET PATH` -- a symlink with exactly this target text.
+  Parent dirs are created.
+- `ensure_block FILE top|bottom NAME CONTENT` -- a marker-delimited block of lines inside a file that otherwise belongs to someone else.
+  `top` goes after a leading `#!` line.  A block found elsewhere in the file is moved.
+- `write_knob PATH VALUE` -- write a sysfs or procfs setting.
+  A change is reported when the read-back value moved, since these files often read back in a different format than they accept.
+- `changed DESCRIPTION` -- report a change you made yourself, with plain commands.
+
+Primitives return nonzero on failure, so they work in chains, outside them, and in conditions alike.
+
+
+Triggers
+--------
+
+```
+pave install GOALS_DIR
+```
+
+Run it as yourself.
+It installs the session and login triggers for you,
+then runs `sudo pave install-boot GOALS_DIR`, which installs one boot trigger and runs the boot phase.
+The boot trigger kind is the first of these the machine has:
+a running systemd, `/etc/local.d/`, `/etc/rc.local`, `/etc/cron.d/`.
+
+Re-running it is harmless, and repoints every trigger if the goals dir changed.
+Every trigger is one line, `"PAVE" run "GOALS_DIR" PHASE`, with both paths absolute as they were when you installed.
+So if pave or the goals dir moves, re-run `pave install`.
+
+### root runs whatever is in those files
+
+The boot trigger has root run pave and your goal files from wherever they are.
+Whoever can write to either can therefore run anything as root on the next boot.
+If that's your user account, your user account is now root-equivalent.
+Decide whether that's acceptable for your machine.
+
+
+Reports
+-------
+
+A run prints what it changed and what failed, then a summary line; a run that did neither prints nothing.
+Each run also writes everything it printed, plus the summary, to a report file:
+
+- root: `/run/pave/PHASE.report` -- `/run` is emptied at every boot, so a missing `boot.report` means no boot trigger fired.
+- users: `${XDG_STATE_HOME:-~/.local/state}/pave/PHASE.report`.
+
+### exit codes
+
+| code | meaning |
+| --- | --- |
+| 0 | everything converged |
+| 4 | usage error |
+| 5 | goals dir not found |
+| 6 | a `*.bash` file in the goals dir isn't named for a phase (the goals still ran) |
+| 7 | a goal failed |
+| 8 | the report dir couldn't be created |
+| 9 | a path can't be written into a trigger (pave allows letters, digits, spaces, and `._/+@,:=~-`) |
+| 10 | no boot hook pave knows how to use |
+| 11 | a trigger couldn't be installed |
+| 12 | `sudo pave install-boot` failed |
+| 13 | `pave install` was run as root |
+
+When more than one applies, the code is for the first one pave ran into.
 
 
 Contributing
@@ -10,6 +196,9 @@ Run `make` to see all commands.
 Before committing anything, run `make all`.
 There should be no warning messages.
 
+The tests (`tests/*.test`) drive the real `bin/pave` against goal files in a scratch dir;
+boot triggers install into a fake root, set by `PAVE_SYSROOT`.
+
 
 License
 -------
diff --git a/bin/pave b/bin/pave
new file mode 100755
index 0000000..894dcdc
--- /dev/null
+++ b/bin/pave
@@ -0,0 +1,50 @@
+#!/bin/bash
+## pave converges a machine onto goal files written in bash.  See the README.
+
+set -euo pipefail
+
+## _pave_self prints the physical path of this script, following any symlink chain used to invoke it.
+## Pure bash with `cd -P` in a subshell, plus plain `readlink`;
+## deliberately not `readlink -f` or `realpath`, which differ across platforms.
+_pave_self() (
+	local src=${BASH_SOURCE[0]} _
+	for _ in {1..40}; do # symlink-chain guard, in the spirit of the kernel's ELOOP
+		case $src in
+		/*/*) cd -P -- "${src%/*}" ;;
+		/*) cd -P -- / ;;
+		*/*) cd -P -- "${src%/*}" ;;
+		esac
+		src=${src##*/}
+		[ -L "$src" ] || break
+		src=$(readlink -- "$src")
+	done
+	printf '%s/%s' "${PWD%/}" "$src"
+)
+
+usage() {
+	cat <<-EOF
+		usage:
+		  pave run GOALS_DIR PHASE      converge this machine onto one phase's goals
+		  pave install GOALS_DIR        install all triggers (as your user; uses sudo for the boot one)
+		  pave install-boot GOALS_DIR   install the boot trigger and run the boot phase (as root)
+
+		phases: ${PAVE_PHASES[*]}
+	EOF
+}
+
+PAVE_BIN=$(_pave_self)
+PAVE_ROOT=${PAVE_BIN%/bin/*}
+source "$PAVE_ROOT/lib/load.bash"
+
+cmd=${1:-}
+shift || :
+case $cmd in
+run) _pave_cmd_run "$@" ;;
+install) _pave_cmd_install "$@" ;;
+install-boot) _pave_cmd_install_boot "$@" ;;
+help | -h | --help) usage ;;
+*)
+	>&2 usage
+	exit 4
+	;;
+esac
diff --git a/lib/fs.bash b/lib/fs.bash
new file mode 100644
index 0000000..2849768
--- /dev/null
+++ b/lib/fs.bash
@@ -0,0 +1,209 @@
+## fs.bash: primitives for files, symlinks, and managed blocks.
+##
+## Every primitive reports a change only when it made one, refuses (rather than clobbers)
+## anything that isn't the kind of thing it manages, and replaces files by temp + rename,
+## so there's never a half-written file.
+## They never rely on errexit, so they behave the same inside chains, outside them, and in conditions.
+
+## _pave_need_abs fails unless every argument is an absolute path.
+## (Relative paths would resolve against the goals dir, which is never what a goal means.)
+_pave_need_abs() {
+	local p
+	for p in "$@"; do
+		if [[ $p != /* ]]; then
+			_pave_error "'$p' is not an absolute path"
+			return 1
+		fi
+	done
+}
+
+## _pave_read_exact reads a whole file into the named variable, trailing newlines included.
+## (`$(<file)` would strip them, and then a file differing only in those would read as converged.)
+##
+## - @param $1 variable name.
+## - @param $2 file path.
+_pave_read_exact() {
+	if [ ! -r "$2" ]; then
+		_pave_error "$2 is not readable"
+		return 1
+	fi
+	# read returns 1 at end of file, which is the normal way for it to stop here.
+	IFS= read -r -d '' "$1" <"$2" || :
+}
+
+## _pave_replace atomically replaces a file with new content and mode.
+##
+## - @param $1 file path.
+## - @param $2 content, written exactly.
+## - @param $3 octal mode, or empty for the umask default.
+_pave_replace() {
+	local path=$1 content=$2 mode=$3 tmp="$1.pave.$$"
+	if ! {
+		printf '%s' "$content" >"$tmp" &&
+			{ [ -z "$mode" ] || chmod "$mode" -- "$tmp"; } &&
+			mv -T -- "$tmp" "$path"
+	}; then
+		rm -f -- "$tmp"
+		return 1
+	fi
+}
+
+## ensure_file converges a regular file's whole content and mode.
+## CONTENT is written followed by one newline,
+## so both `"$(< somefile)"` and quoted literals come out as conventional text files.
+## Parent directories are created as needed.
+##
+## - @param $1 absolute path.
+## - @param $2 octal mode, e.g. 644.
+## - @param $3 content.
+ensure_file() {
+	if [ "$#" -ne 3 ]; then
+		_pave_error "usage: ensure_file PATH MODE CONTENT"
+		return 1
+	fi
+	local path=$1 mode=$2 want=$3$'\n' have='' havemode
+	_pave_need_abs "$path" || return 1
+	if [[ ! $mode =~ ^[0-7]{3,4}$ ]]; then
+		_pave_error "ensure_file $path: mode '$mode' is not octal"
+		return 1
+	fi
+	mode=$(printf '%o' "$((8#$mode))") # the same spelling `stat -c %a` uses
+
+	if [ -L "$path" ] || { [ -e "$path" ] && [ ! -f "$path" ]; }; then
+		_pave_error "$path exists and is not a regular file; refusing to replace it"
+		return 1
+	fi
+	if [ ! -e "$path" ]; then
+		mkdir -p -- "${path%/*}/" || return 1
+		_pave_replace "$path" "$want" "$mode" || return 1
+		changed "created $path"
+		return 0
+	fi
+
+	_pave_read_exact have "$path" || return 1
+	havemode=$(stat -c %a -- "$path") || return 1
+	if [ "$have" != "$want" ]; then
+		_pave_replace "$path" "$want" "$mode" || return 1
+		changed "rewrote $path"
+	elif [ "$havemode" != "$mode" ]; then
+		chmod "$mode" -- "$path" || return 1
+		changed "chmod $mode $path (was $havemode)"
+	fi
+}
+
+## ensure_link converges a symlink to have exactly the given target text.
+## Parent directories are created as needed.
+##
+## - @param $1 target text (relative targets are relative to the link's directory, as always).
+## - @param $2 absolute path of the link.
+ensure_link() {
+	if [ "$#" -ne 2 ]; then
+		_pave_error "usage: ensure_link TARGET PATH"
+		return 1
+	fi
+	local target=$1 path=$2 have tmp
+	_pave_need_abs "$path" || return 1
+	if [ -L "$path" ]; then
+		have=$(readlink -- "$path") || return 1
+		[ "$have" = "$target" ] && return 0
+		tmp="$path.pave.$$"
+		if ! { ln -s -- "$target" "$tmp" && mv -T -- "$tmp" "$path"; }; then
+			rm -f -- "$tmp"
+			return 1
+		fi
+		changed "repointed $path -> $target (was $have)"
+	elif [ -e "$path" ]; then
+		_pave_error "$path exists and is not a symlink; refusing to replace it"
+		return 1
+	else
+		mkdir -p -- "${path%/*}/" || return 1
+		ln -s -- "$target" "$path" || return 1
+		changed "linked $path -> $target"
+	fi
+}
+
+## ensure_block converges one named, marker-delimited block of lines
+## inside a file that otherwise belongs to someone else (a shell rc file, rc.local, ...).
+## Exactly one block of that name ends up in the file, with the given content, at the given end.
+## A block found elsewhere in the file is moved there.
+## 'top' goes after a leading `#!` line, if the file has one.
+## The file's mode is kept; a file that doesn't exist yet is created.
+##
+## - @param $1 absolute path of the file.
+## - @param $2 placement: top or bottom.
+## - @param $3 block name; becomes part of the marker lines.
+## - @param $4 content: the lines between the markers.
+ensure_block() {
+	if [ "$#" -ne 4 ]; then
+		_pave_error "usage: ensure_block FILE top|bottom NAME CONTENT"
+		return 1
+	fi
+	local file=$1 placement=$2 name=$3 content=$4
+	_pave_need_abs "$file" || return 1
+	case $placement in
+	top | bottom) ;;
+	*)
+		_pave_error "ensure_block $file: placement must be top or bottom, not '$placement'"
+		return 1
+		;;
+	esac
+	if [ -z "$name" ] || [[ $name == *$'\n'* ]]; then
+		_pave_error "ensure_block $file: block name must be one non-empty line"
+		return 1
+	fi
+	local begin="# >>> pave:$name >>>" end="# <<< pave:$name <<<"
+	local block=$begin$'\n'$content$'\n'$end
+
+	local -a others=()
+	local in_block=0 saw_block=0 line have='' mode=''
+	if [ -e "$file" ]; then
+		if [ -L "$file" ] || [ ! -f "$file" ]; then
+			_pave_error "$file exists and is not a regular file; refusing to replace it"
+			return 1
+		fi
+		_pave_read_exact have "$file" || return 1
+		mode=$(stat -c %a -- "$file") || return 1
+		while IFS= read -r line || [ -n "$line" ]; do
+			if [ "$in_block" -eq 0 ] && [ "$line" = "$begin" ]; then
+				in_block=1
+				saw_block=1
+			elif [ "$in_block" -eq 1 ]; then
+				[ "$line" = "$end" ] && in_block=0
+			else
+				others+=("$line")
+			fi
+		done <<<"${have%$'\n'}"
+		if [ "$in_block" -eq 1 ]; then
+			_pave_error "$file: block '$name' has a begin marker but no end marker (fix the file by hand)"
+			return 1
+		fi
+	fi
+
+	# `<<<` adds a newline, so an empty file reads as one empty line; that's not content.
+	if [ "${#others[@]}" -eq 1 ] && [ -z "${others[0]}" ]; then
+		others=()
+	fi
+	local -a lines=()
+	if [ "$placement" = top ]; then
+		if [ "${#others[@]}" -gt 0 ] && [[ ${others[0]} == '#!'* ]]; then
+			lines=("${others[0]}" "$block" "${others[@]:1}")
+		else
+			lines=("$block" "${others[@]}")
+		fi
+	else
+		lines=("${others[@]}" "$block")
+	fi
+	local want
+	printf -v want '%s\n' "${lines[@]}"
+	[ "$have" = "$want" ] && return 0
+
+	if [ ! -e "$file" ]; then
+		mkdir -p -- "${file%/*}/" || return 1
+	fi
+	_pave_replace "$file" "$want" "$mode" || return 1
+	if [ "$saw_block" -eq 1 ]; then
+		changed "updated block '$name' in $file"
+	else
+		changed "added block '$name' to $file"
+	fi
+}
diff --git a/lib/install.bash b/lib/install.bash
new file mode 100644
index 0000000..61f3931
--- /dev/null
+++ b/lib/install.bash
@@ -0,0 +1,158 @@
+## install.bash: `pave install` and `pave install-boot`, which set up the triggers that call `pave run`.
+##
+## Every trigger's payload is one `pave run` line with absolute paths baked in:
+## triggers run with other users' homes, other working dirs, and minimal environments.
+## Boot triggers go under $PAVE_SYSROOT (default: the real root), so they can be installed into a fake root.
+
+## _pave_trigger_path_ok fails unless a path can be written into every trigger format unescaped.
+## (Each format has its own quoting rules -- shell, systemd, desktop entries, cron --
+## and a conservative character set is simpler than four escapers.)
+_pave_trigger_path_ok() {
+	if [[ ! $1 =~ ^/[A-Za-z0-9._/+@,:=~\ -]*$ ]]; then
+		_pave_error "'$1' has characters pave won't put in a trigger; use a path made of letters, digits, spaces, and ._/+@,:=~-"
+		return 1
+	fi
+}
+
+## _pave_payload prints the one command line every trigger runs.
+##
+## - @param $1 goals dir.
+## - @param $2 phase.
+_pave_payload() {
+	printf '"%s" run "%s" %s' "$PAVE_BIN" "$1" "$2"
+}
+
+## _pave_detect_boot prints the boot trigger kind for this machine: the first match in a fixed order.
+## The order goes from the most certain evidence (a running systemd) to the least
+## (a cron.d directory doesn't prove a cron daemon reads it).
+_pave_detect_boot() {
+	local r=${PAVE_SYSROOT:-}
+	if [ -d "$r/run/systemd/system" ]; then
+		printf systemd
+	elif [ -d "$r/etc/local.d" ]; then
+		printf openrc-local
+	elif [ -f "$r/etc/rc.local" ]; then
+		printf rc-local
+	elif [ -d "$r/etc/cron.d" ]; then
+		printf cron-reboot
+	else
+		_pave_error "found no boot hook pave knows: no systemd, /etc/local.d, /etc/rc.local, or /etc/cron.d"
+		return 1
+	fi
+}
+
+_pave_trigger_systemd() {
+	local d="${PAVE_SYSROOT:-}/etc/systemd/system"
+	ensure_file "$d/pave-boot.service" 644 "[Unit]
+Description=pave boot goals
+After=local-fs.target
+
+[Service]
+Type=oneshot
+ExecStart=$(_pave_payload "$1" boot)
+
+[Install]
+WantedBy=multi-user.target" || return 1
+	# What `systemctl enable` would do, without needing systemctl.
+	ensure_link ../pave-boot.service "$d/multi-user.target.wants/pave-boot.service"
+}
+
+_pave_trigger_openrc_local() {
+	ensure_file "${PAVE_SYSROOT:-}/etc/local.d/pave.start" 755 "#!/bin/sh
+exec $(_pave_payload "$1" boot)"
+}
+
+_pave_trigger_rc_local() {
+	local f="${PAVE_SYSROOT:-}/etc/rc.local"
+	# `|| :` because some rc.local files run under `sh -e`, and pave's failures shouldn't stop the rest of it.
+	ensure_block "$f" top trigger "$(_pave_payload "$1" boot) || :" || return 1
+	if [ ! -x "$f" ]; then
+		chmod +x -- "$f" || return 1
+		changed "chmod +x $f"
+	fi
+}
+
+_pave_trigger_cron_reboot() {
+	ensure_file "${PAVE_SYSROOT:-}/etc/cron.d/pave" 644 "@reboot root $(_pave_payload "$1" boot)"
+}
+
+_pave_trigger_xdg_autostart() {
+	ensure_file "${XDG_CONFIG_HOME:-$HOME/.config}/autostart/pave-session.desktop" 644 "[Desktop Entry]
+Type=Application
+Name=pave session goals
+Exec=$(_pave_payload "$1" session)
+NoDisplay=true"
+}
+
+## _pave_trigger_profile adds the login trigger to whichever file bash reads for login shells:
+## the first of these that exists, else ~/.profile.
+_pave_trigger_profile() {
+	local f
+	for f in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do
+		[ -e "$f" ] && break
+	done
+	ensure_block "$f" bottom trigger "$(_pave_payload "$1" login)"
+}
+
+## _pave_resolve_goals prints the goals dir as an absolute path, keeping symlinks as given,
+## so repointing a symlinked goals dir needs no reinstall.
+_pave_resolve_goals() {
+	if ! (cd -- "$1" 2>/dev/null && pwd); then
+		_pave_error "goals dir '$1' not found"
+		return 1
+	fi
+}
+
+## _pave_cmd_install implements `pave install GOALS_DIR`:
+## installs this user's triggers, then the boot trigger via sudo.
+##
+## - @return 0, or an exit code from the table in the README.
+_pave_cmd_install() {
+	if [ "$#" -ne 1 ]; then
+		_pave_error "usage: pave install GOALS_DIR"
+		return 4
+	fi
+	if [ "$EUID" -eq 0 ]; then
+		_pave_error "run pave install as the user whose session and login triggers it should install; it uses sudo for the boot trigger"
+		return 13
+	fi
+	local dir
+	dir=$(_pave_resolve_goals "$1") || return 5
+	_pave_trigger_path_ok "$PAVE_BIN" || return 9
+	_pave_trigger_path_ok "$dir" || return 9
+
+	_PAVE_TALLY=/dev/null # install reports changes, but doesn't count them
+	local code=0
+	_pave_trigger_xdg_autostart "$dir" || code=11
+	_pave_trigger_profile "$dir" || code=11
+
+	echo "pave: installing the boot trigger, via sudo"
+	if ! sudo -- "$PAVE_BIN" install-boot "$dir"; then
+		_pave_error "sudo pave install-boot failed"
+		[ "$code" -ne 0 ] || code=12
+	fi
+	return "$code"
+}
+
+## _pave_cmd_install_boot implements `pave install-boot GOALS_DIR`:
+## installs the boot trigger for this machine, then runs the boot phase,
+## so its goals take effect now rather than at next boot.
+## Normally called by `pave install`, via sudo.
+##
+## - @return 0, or an exit code from the table in the README.
+_pave_cmd_install_boot() {
+	if [ "$#" -ne 1 ]; then
+		_pave_error "usage: pave install-boot GOALS_DIR"
+		return 4
+	fi
+	local dir kind
+	dir=$(_pave_resolve_goals "$1") || return 5
+	_pave_trigger_path_ok "$PAVE_BIN" || return 9
+	_pave_trigger_path_ok "$dir" || return 9
+	kind=$(_pave_detect_boot) || return 10
+
+	_PAVE_TALLY=/dev/null # install reports changes, but doesn't count them
+	echo "pave: boot trigger kind: $kind"
+	"_pave_trigger_${kind//-/_}" "$dir" || return 11
+	"$PAVE_BIN" run "$dir" boot
+}
diff --git a/lib/knob.bash b/lib/knob.bash
new file mode 100644
index 0000000..5eed1fd
--- /dev/null
+++ b/lib/knob.bash
@@ -0,0 +1,24 @@
+## knob.bash: primitives for kernel settings files (sysfs, procfs).
+
+## write_knob writes a value into a kernel settings file.
+## These files often read back in a different format than they accept
+## (`lru_gen/enabled` takes `y` and reads back a hex bitmask),
+## so a change is reported when the read-back moved, not by comparing it to VALUE.
+## The write happens every time; these files reset at boot anyway, and rewriting a value is harmless.
+##
+## - @param $1 absolute path of the settings file.
+## - @param $2 value to write.
+write_knob() {
+	if [ "$#" -ne 2 ]; then
+		_pave_error "usage: write_knob PATH VALUE"
+		return 1
+	fi
+	local path=$1 value=$2 before after
+	_pave_need_abs "$path" || return 1
+	before=$(<"$path") || return 1
+	printf '%s\n' "$value" >"$path" || return 1
+	after=$(<"$path") || return 1
+	if [ "$before" != "$after" ]; then
+		changed "$path: $before -> $after (wrote $value)"
+	fi
+}
diff --git a/lib/load.bash b/lib/load.bash
new file mode 100644
index 0000000..fb7fdf4
--- /dev/null
+++ b/lib/load.bash
@@ -0,0 +1,8 @@
+## load.bash sources the whole pave library.
+## Requires PAVE_ROOT: the directory containing lib/.
+
+source "$PAVE_ROOT/lib/report.bash"
+source "$PAVE_ROOT/lib/run.bash"
+source "$PAVE_ROOT/lib/fs.bash"
+source "$PAVE_ROOT/lib/knob.bash"
+source "$PAVE_ROOT/lib/install.bash"
diff --git a/lib/report.bash b/lib/report.bash
new file mode 100644
index 0000000..cb37ff6
--- /dev/null
+++ b/lib/report.bash
@@ -0,0 +1,45 @@
+## report.bash: how pave says what happened.
+##
+## Every run keeps a tally file: one 'c' per change and one 'f' per failure,
+## appended by whichever shell (or subshell) observed it.
+## A file rather than a variable, because chains and goal files run in subshells,
+## and their counts have to survive back to the runner.
+
+## changed reports one act of convergence.
+## Goal files may call it too, when converging by hand with plain commands.
+##
+## - @param $1... description of the change.
+## - @stdout the report line.
+changed() {
+	printf 'pave: changed: %s\n' "$*"
+	printf c >>"$_PAVE_TALLY"
+}
+
+## _pave_error explains why something can't be converged.
+## It's a reason, not a failure record: whoever then fails records that, with a location.
+##
+## - @param $1... the explanation.
+## - @stderr the explanation.
+_pave_error() {
+	>&2 printf 'pave: error: %s\n' "$*"
+}
+
+## _pave_fail records one failure.
+##
+## - @param $1... what failed, and where.
+## - @stderr the failure line.
+_pave_fail() {
+	>&2 printf 'pave: FAIL: %s\n' "$*"
+	printf f >>"$_PAVE_TALLY"
+}
+
+## _pave_count prints how many of one tally letter have been recorded.
+##
+## - @param $1 the letter: c or f.
+## - @stdout the count.
+_pave_count() {
+	local t=''
+	IFS= read -r t <"$_PAVE_TALLY" || :
+	t=${t//[^$1]/}
+	printf '%d' "${#t}"
+}
diff --git a/lib/run.bash b/lib/run.bash
new file mode 100644
index 0000000..459138e
--- /dev/null
+++ b/lib/run.bash
@@ -0,0 +1,181 @@
+## run.bash: `pave run`, and the goal file semantics it provides.
+
+PAVE_PHASES=(boot session login manual)
+
+## _pave_is_phase succeeds if $1 is a known phase name.
+_pave_is_phase() {
+	local p
+	for p in "${PAVE_PHASES[@]}"; do
+		[ "$p" = "$1" ] && return 0
+	done
+	return 1
+}
+
+## _pave_report_dir prints where run reports go.
+## Root's reports go in /run, which is emptied every boot,
+## so a missing boot report after boot means no boot trigger fired.
+## Users' go in the XDG state dir, which (unlike the XDG runtime dir) always exists.
+##
+## - @stdout the directory path.
+_pave_report_dir() {
+	if [ "$EUID" -eq 0 ]; then
+		printf '/run/pave'
+	else
+		printf '%s/pave' "${XDG_STATE_HOME:-$HOME/.local/state}"
+	fi
+}
+
+## _pave_cmd_run implements `pave run GOALS_DIR PHASE`.
+## The run's output (stdout and stderr together) goes both to stdout and to the report file,
+## which appears only once the run is complete.
+## A run that changed nothing and failed nothing prints nothing; its report still gets a summary.
+##
+## - @param $1 goals dir.
+## - @param $2 phase.
+## - @return 0, or an exit code from the table in the README.
+_pave_cmd_run() {
+	if [ "$#" -ne 2 ]; then
+		_pave_error "usage: pave run GOALS_DIR PHASE"
+		return 4
+	fi
+	local dir=$1 phase=$2
+	if ! _pave_is_phase "$phase"; then
+		_pave_error "unknown phase '$phase' (phases are: ${PAVE_PHASES[*]})"
+		return 4
+	fi
+	if ! dir=$(cd -- "$dir" 2>/dev/null && pwd); then
+		_pave_error "goals dir '$1' not found"
+		return 5
+	fi
+	local rdir
+	rdir=$(_pave_report_dir)
+	if ! mkdir -p -- "$rdir"; then
+		_pave_error "cannot create report dir $rdir"
+		return 8
+	fi
+
+	local tmp="$rdir/$phase.report.$$"
+	_PAVE_TALLY="$rdir/.$phase.tally.$$"
+	: >"$_PAVE_TALLY"
+	local rc
+	# Not in a condition context: that would disable errexit inside chains.
+	set +e
+	(cd -- "$dir" && _pave_run_phase "$phase") </dev/null 2>&1 | tee -- "$tmp"
+	rc=${PIPESTATUS[0]}
+	set -e
+
+	local nc nf summary
+	nc=$(_pave_count c)
+	nf=$(_pave_count f)
+	rm -f -- "$_PAVE_TALLY"
+	summary="pave: $phase: $nc changed, $nf failed (goals: $dir, host: $HOSTNAME)"
+	printf '%s\n' "$summary" >>"$tmp"
+	mv -- "$tmp" "$rdir/$phase.report"
+	if [ "$nc" -ne 0 ] || [ "$nf" -ne 0 ]; then
+		printf '%s\n' "$summary"
+	fi
+	return "$rc"
+}
+
+## _pave_run_phase runs the common and then the host goal file for one phase.
+## Expects to run with the goals dir as cwd, in a subshell of its own, with errexit off.
+##
+## - @param $1 phase.
+## - @return 0; 6 if a file in the goals dir isn't named for a phase; else 7 if anything failed.
+_pave_run_phase() {
+	local phase=$1 f name code=0
+
+	# Phase file names are checked in every run, so a misspelled one can't silently never run.
+	shopt -s nullglob
+	for f in ./*.bash ./hosts/*/*.bash; do
+		name=${f##*/}
+		_pave_is_phase "${name%.bash}" && continue
+		_pave_fail "$f: not named for a phase (phases are: ${PAVE_PHASES[*]})"
+		code=6
+	done
+	shopt -u nullglob
+
+	for f in "./$phase.bash" "./hosts/$HOSTNAME/$phase.bash"; do
+		[ -f "$f" ] || continue
+		_pave_run_file "$f"
+	done
+
+	if [ "$code" -eq 0 ] && [ "$(_pave_count f)" -ne 0 ]; then
+		code=7
+	fi
+	return "$code"
+}
+
+## _pave_run_file sources one goal file, in a subshell of its own.
+## Top-level commands run without errexit: a failure is recorded, and the file carries on.
+## Things that abort the whole file (an unset variable, an `exit`) end only this subshell.
+## A file with a syntax error doesn't run at all, rather than running up to the error.
+##
+## - @param $1 goal file path, relative to the goals dir.
+_pave_run_file() {
+	local f=$1 rc
+	if ! bash -n -- "$f"; then
+		_pave_fail "$f: syntax error; skipped the whole file"
+		return 0
+	fi
+	(
+		_PAVE_FILE_SUBSHELL=$BASH_SUBSHELL
+		_PAVE_ACKED=$(_pave_count f)
+		_PAVE_CHAIN=''
+		set +e -Euo pipefail
+		trap _pave_on_err ERR
+		# shellcheck source=/dev/null
+		source "$f"
+		exit 0
+	)
+	# Deliberately not `( ... ) || rc=$?`: that's a condition context, which disables errexit inside chains.
+	rc=$?
+	if [ "$rc" -ne 0 ]; then
+		_pave_fail "$f: aborted (exit $rc); the rest of the file was skipped"
+	fi
+}
+
+## _pave_on_err is the ERR trap for goal files.
+## It records each failure once, at the deepest shell where it happened.
+## A failure inside a subshell (a chain, a command substitution) also shows up
+## as a failed subshell in every enclosing shell.
+## Each shell knows how many failures it has accounted for;
+## if the tally holds more, a deeper shell already recorded this one.
+_pave_on_err() {
+	local rc=$? n
+	# `source` returns the status of the file's last command, which was already recorded.
+	[ "${FUNCNAME[1]}" = _pave_run_file ] && return
+	n=$(_pave_count f)
+	if [ "$n" -gt "$_PAVE_ACKED" ]; then
+		_PAVE_ACKED=$n
+		return
+	fi
+	_pave_fail "${_PAVE_CHAIN:+chain $_PAVE_CHAIN: }exit $rc at ${BASH_SOURCE[1]}:${BASH_LINENO[0]}"
+	_PAVE_ACKED=$((n + 1))
+}
+
+## chain makes the rest of its subshell stop at the first failing command.
+## Use it as the first command of a parenthesized group:
+##
+##     ( chain NAME
+##         step one
+##         step two
+##     )
+##
+## A failed chain counts as one failure; the goal file carries on after it.
+## Called outside a subshell, it refuses, since it would otherwise
+## switch on errexit for the rest of the whole goal file.
+##
+## - @param $1 name, for reports.
+chain() {
+	if [ "$BASH_SUBSHELL" -le "$_PAVE_FILE_SUBSHELL" ]; then
+		_pave_error "chain ${1:-}: not inside ( ... ); refusing to switch on errexit for the whole file"
+		return 1
+	fi
+	if [ "$#" -ne 1 ] || [ -z "$1" ]; then
+		_pave_error "usage: ( chain NAME ... )"
+		exit 1
+	fi
+	_PAVE_CHAIN=$1
+	set -e
+}
diff --git a/tests/install.test b/tests/install.test
new file mode 100755
index 0000000..3e4e044
--- /dev/null
+++ b/tests/install.test
@@ -0,0 +1,132 @@
+#!/bin/bash
+## Tests for `pave install` and `pave install-boot`.
+##
+## Boot triggers go into a fake root (PAVE_SYSROOT), and a fake `sudo` on PATH just runs its command,
+## so the whole install flow runs as the current user.
+## Installed triggers are then actually executed, to prove their quoting works;
+## the goals dir has a space in its name to make that mean something.
+
+# shellcheck source=tests/testlib.bash
+source "${BASH_SOURCE[0]%/*}/testlib.bash"
+
+mkdir -p "$scratch/fakebin"
+printf '#!/bin/bash\n[ "$1" = -- ] && shift\nexec "$@"\n' >"$scratch/fakebin/sudo"
+chmod +x "$scratch/fakebin/sudo"
+export PATH="$scratch/fakebin:$PATH"
+export PAVE_SYSROOT="$scratch/root"
+
+G="$scratch/my goals"
+M="$scratch/marks"
+for phase in boot session login; do
+	goals "$G/$phase.bash" <<EOF
+ensure_file "$M/$phase" 644 "\$PWD"
+EOF
+done
+
+## fresh_root empties the fake root and run marks, then creates the given dirs and files in the root.
+##
+## - @param $1... paths under the root; ending in / makes a dir, otherwise an empty file.
+fresh_root() {
+	rm -rf -- "$PAVE_SYSROOT" "$M"
+	local p
+	for p in "$@"; do
+		if [[ $p == */ ]]; then
+			mkdir -p -- "$PAVE_SYSROOT/$p"
+		else
+			mkdir -p -- "$PAVE_SYSROOT/${p%/*}"
+			: >"$PAVE_SYSROOT/$p"
+		fi
+	done
+}
+
+# ---- systemd, plus the user triggers; then idempotence and repointing ----
+
+fresh_root run/systemd/system/
+pv install "$G"
+assert_eq "systemd: exit 0" 0 "$rc"
+assert_has "systemd: kind reported" "pave: boot trigger kind: systemd"
+unit="$PAVE_SYSROOT/etc/systemd/system/pave-boot.service"
+assert "systemd: unit has the payload" grep -qxF "ExecStart=\"$PAVE\" run \"$G\" boot" "$unit"
+assert_eq "systemd: unit enabled" ../pave-boot.service \
+	"$(readlink "$PAVE_SYSROOT/etc/systemd/system/multi-user.target.wants/pave-boot.service")"
+assert_eq "install runs the boot phase" "$G" "$(<"$M/boot")"
+
+desktop="$HOME/.config/autostart/pave-session.desktop"
+assert "xdg-autostart: desktop entry has the payload" grep -qxF "Exec=\"$PAVE\" run \"$G\" session" "$desktop"
+assert "profile: block added to ~/.profile" grep -qxF "\"$PAVE\" run \"$G\" login" "$HOME/.profile"
+
+rm -rf -- "$M"
+sh -c "$(sed -n 's/^ExecStart=//p' "$unit")" >/dev/null
+assert_eq "systemd: ExecStart line runs the boot phase" "$G" "$(<"$M/boot")"
+sh -c "$(sed -n 's/^Exec=//p' "$desktop")" >/dev/null
+assert_eq "xdg-autostart: Exec line runs the session phase" "$G" "$(<"$M/session")"
+sh -c ". \"\$HOME/.profile\"" >/dev/null
+assert_eq "profile: sourcing it runs the login phase" "$G" "$(<"$M/login")"
+
+pv install "$G"
+assert_eq "reinstall: exit 0" 0 "$rc"
+assert_count "reinstall: changes nothing" "pave: changed" 0
+
+G2="$scratch/other-goals"
+goals "$G2/boot.bash" <<<'true'
+pv install "$G2"
+assert "repoint: unit follows the new goals dir" grep -qxF "ExecStart=\"$PAVE\" run \"$G2\" boot" "$unit"
+assert_eq "repoint: one profile block, updated" 1 "$(grep -c '>>> pave:trigger >>>' "$HOME/.profile")"
+assert "repoint: profile block follows" grep -qxF "\"$PAVE\" run \"$G2\" login" "$HOME/.profile"
+
+# ---- the login trigger goes where bash will read it ----
+
+rm -f "$HOME/.profile"
+: >"$HOME/.bash_profile"
+pv install "$G"
+assert "profile: ~/.bash_profile used when it exists" grep -qF 'pave:trigger' "$HOME/.bash_profile"
+assert "profile: ~/.profile left alone then" test ! -e "$HOME/.profile"
+
+# ---- the other boot kinds ----
+
+fresh_root etc/local.d/
+pv install "$G"
+assert_has "openrc-local: kind reported" "pave: boot trigger kind: openrc-local"
+start="$PAVE_SYSROOT/etc/local.d/pave.start"
+assert_eq "openrc-local: executable" 755 "$(stat -c %a "$start")"
+rm -rf -- "$M"
+"$start" >/dev/null
+assert_eq "openrc-local: script runs the boot phase" "$G" "$(<"$M/boot")"
+
+fresh_root etc/rc.local etc/cron.d/
+printf '#!/bin/sh -e\nexit 0\n' >"$PAVE_SYSROOT/etc/rc.local"
+pv install "$G"
+assert_has "rc-local: preferred over cron" "pave: boot trigger kind: rc-local"
+assert_eq "rc-local: made executable" 755 "$(stat -c %a "$PAVE_SYSROOT/etc/rc.local")"
+assert_eq "rc-local: block after the shebang, before exit" "#!/bin/sh -e" "$(head -n1 "$PAVE_SYSROOT/etc/rc.local")"
+assert_eq "rc-local: exit stays last" "exit 0" "$(tail -n1 "$PAVE_SYSROOT/etc/rc.local")"
+rm -rf -- "$M"
+"$PAVE_SYSROOT/etc/rc.local" >/dev/null
+assert_eq "rc-local: script runs the boot phase" "$G" "$(<"$M/boot")"
+
+fresh_root etc/cron.d/
+pv install "$G"
+assert_has "cron-reboot: kind reported" "pave: boot trigger kind: cron-reboot"
+assert "cron-reboot: entry has the payload" grep -qxF "@reboot root \"$PAVE\" run \"$G\" boot" "$PAVE_SYSROOT/etc/cron.d/pave"
+
+# ---- refusals ----
+
+fresh_root
+pv install-boot "$G"
+assert_eq "no boot hook: install-boot exit 10" 10 "$rc"
+assert_has "no boot hook: explained" "found no boot hook pave knows"
+pv install "$G"
+assert_eq "no boot hook: install exit 12" 12 "$rc"
+assert "no boot hook: user triggers still installed" test -f "$desktop"
+
+fresh_root run/systemd/system/
+mkdir -p "$scratch/bad\$dir"
+pv install "$scratch/bad\$dir"
+assert_eq "unsafe path: exit 9" 9 "$rc"
+assert "unsafe path: nothing installed" test ! -e "$unit"
+pv install "$scratch/no-such-goals"
+assert_eq "missing goals dir: exit 5" 5 "$rc"
+pv install
+assert_eq "usage: exit 4" 4 "$rc"
+
+verdict
diff --git a/tests/primitives.test b/tests/primitives.test
new file mode 100755
index 0000000..5a0e2b9
--- /dev/null
+++ b/tests/primitives.test
@@ -0,0 +1,176 @@
+#!/bin/bash
+## Tests for the goal primitives: ensure_file, ensure_link, ensure_block, write_knob.
+## Each is exercised through `pave run`, the way goal files use them.
+
+# shellcheck source=tests/testlib.bash
+source "${BASH_SOURCE[0]%/*}/testlib.bash"
+
+G="$scratch/goals"
+T="$scratch/t"
+mkdir -p "$T"
+
+## converge writes a manual-phase goal file and runs it.
+##
+## - @stdin the goal file.
+converge() {
+	goals "$G/manual.bash"
+	pv run "$G" manual
+}
+
+# ---- ensure_file ----
+
+converge <<EOF
+ensure_file "$T/deep/er/f" 640 "line one
+line two"
+EOF
+assert_has "ensure_file: creation reported" "pave: changed: created $T/deep/er/f"
+assert_eq "ensure_file: content plus one newline" $'line one\nline two\n.' "$(exact "$T/deep/er/f")"
+assert_eq "ensure_file: mode" 640 "$(stat -c %a "$T/deep/er/f")"
+pv run "$G" manual
+assert_eq "ensure_file: converged is quiet" "" "$out"
+
+printf 'line one\nline two\n\n' >"$T/deep/er/f"
+pv run "$G" manual
+assert_has "ensure_file: an extra trailing newline is drift" "pave: changed: rewrote $T/deep/er/f"
+chmod 600 "$T/deep/er/f"
+pv run "$G" manual
+assert_has "ensure_file: mode drift fixed alone" "pave: changed: chmod 640 $T/deep/er/f (was 600)"
+assert_count "ensure_file: mode drift doesn't rewrite" "rewrote" 0
+
+mkdir "$T/adir"
+ln -s elsewhere "$T/alink"
+converge <<EOF
+ensure_file "$T/adir" 644 x
+ensure_file "$T/alink" 644 x
+ensure_file relative/path 644 x
+ensure_file "$T/badmode" rw x
+ensure_file "$T/toofew" 644
+EOF
+assert_eq "ensure_file refusals: exit 7" 7 "$rc"
+assert_has "ensure_file: refuses a directory" "pave: error: $T/adir exists and is not a regular file"
+assert_has "ensure_file: refuses a symlink" "pave: error: $T/alink exists and is not a regular file"
+assert_has "ensure_file: refuses a relative path" "pave: error: 'relative/path' is not an absolute path"
+assert_has "ensure_file: refuses a non-octal mode" "mode 'rw' is not octal"
+assert_has "ensure_file: refuses wrong arg count" "usage: ensure_file PATH MODE CONTENT"
+assert_count "ensure_file: each refusal recorded" "pave: FAIL" 5
+assert_eq "ensure_file: symlink untouched" elsewhere "$(readlink "$T/alink")"
+
+# ---- ensure_link ----
+
+converge <<EOF
+ensure_link ../target "$T/links/l"
+EOF
+assert_has "ensure_link: creation reported" "pave: changed: linked $T/links/l -> ../target"
+assert_eq "ensure_link: exact target text" ../target "$(readlink "$T/links/l")"
+pv run "$G" manual
+assert_eq "ensure_link: converged is quiet" "" "$out"
+ln -sfn /wrong "$T/links/l"
+pv run "$G" manual
+assert_has "ensure_link: repoint reported" "pave: changed: repointed $T/links/l -> ../target (was /wrong)"
+assert_eq "ensure_link: repointed" ../target "$(readlink "$T/links/l")"
+
+touch "$T/plainfile"
+converge <<EOF
+ensure_link x "$T/plainfile"
+EOF
+assert_has "ensure_link: refuses a non-symlink" "pave: error: $T/plainfile exists and is not a symlink"
+assert "ensure_link: non-symlink untouched" test -f "$T/plainfile" -a ! -L "$T/plainfile"
+
+# ---- ensure_block ----
+
+B="$T/rc"
+printf '#!/bin/sh -e\necho existing\n\nexit 0\n' >"$B"
+chmod 751 "$B"
+converge <<EOF
+ensure_block "$B" top first "echo one
+echo two"
+EOF
+assert_has "ensure_block: addition reported" "pave: changed: added block 'first' to $B"
+assert_eq "ensure_block: top goes after the shebang" \
+	$'#!/bin/sh -e\n# >>> pave:first >>>\necho one\necho two\n# <<< pave:first <<<\necho existing\n\nexit 0' "$(<"$B")"
+assert_eq "ensure_block: mode kept" 751 "$(stat -c %a "$B")"
+snap=$(exact "$B")
+pv run "$G" manual
+assert_eq "ensure_block: converged is quiet" "" "$out"
+assert_eq "ensure_block: converged file byte-identical" "$snap" "$(exact "$B")"
+
+converge <<EOF
+ensure_block "$B" top first "echo changed"
+ensure_block "$B" bottom second "echo last"
+EOF
+assert_has "ensure_block: update reported" "pave: changed: updated block 'first' in $B"
+assert_eq "ensure_block: updated in place, second at bottom, rest kept" \
+	$'#!/bin/sh -e\n# >>> pave:first >>>\necho changed\n# <<< pave:first <<<\necho existing\n\nexit 0\n# >>> pave:second >>>\necho last\n# <<< pave:second <<<' "$(<"$B")"
+
+converge <<EOF
+ensure_block "$B" top first "echo changed"
+ensure_block "$B" top second "echo last"
+EOF
+assert_eq "ensure_block: moved to the requested end" \
+	$'#!/bin/sh -e\n# >>> pave:second >>>\necho last\n# <<< pave:second <<<\n# >>> pave:first >>>\necho changed\n# <<< pave:first <<<\necho existing\n\nexit 0' "$(<"$B")"
+
+converge <<EOF
+ensure_block "$T/fresh/rc" bottom b "x"
+EOF
+assert_eq "ensure_block: creates a missing file" $'# >>> pave:b >>>\nx\n# <<< pave:b <<<' "$(<"$T/fresh/rc")"
+: >"$T/empty"
+converge <<EOF
+ensure_block "$T/empty" top b "x"
+EOF
+assert_eq "ensure_block: an empty file gains no blank line" $'# >>> pave:b >>>\nx\n# <<< pave:b <<<' "$(<"$T/empty")"
+
+printf 'keep\n# >>> pave:b >>>\nhalf a block\n' >"$T/corrupt"
+converge <<EOF
+ensure_block "$T/corrupt" top b "x"
+ensure_block "$T/empty" middle b "x"
+EOF
+assert_has "ensure_block: refuses a block with no end marker" "block 'b' has a begin marker but no end marker"
+assert_eq "ensure_block: corrupt file untouched" $'keep\n# >>> pave:b >>>\nhalf a block' "$(<"$T/corrupt")"
+assert_has "ensure_block: refuses an unknown placement" "placement must be top or bottom"
+
+# ---- write_knob (against plain files; real sysfs can't be faked) ----
+
+printf 'old\n' >"$T/knob"
+converge <<EOF
+write_knob "$T/knob" new
+EOF
+assert_has "write_knob: change reported with before and after" "pave: changed: $T/knob: old -> new (wrote new)"
+pv run "$G" manual
+assert_eq "write_knob: unchanged read-back is quiet" "" "$out"
+converge <<EOF
+write_knob "$T/no-such-knob" 1
+EOF
+assert_eq "write_knob: missing file fails" 7 "$rc"
+
+# ---- primitives fail correctly even where errexit is off: in conditions ----
+
+touch "$T/notadir"
+converge <<EOF
+if ensure_file "$T/notadir/f" 644 x; then echo WRONG-file; fi
+if ensure_link x "$T/notadir/l"; then echo WRONG-link; fi
+if ensure_block "$T/notadir/b" top b x; then echo WRONG-block; fi
+if write_knob "$T/notadir/k" 1; then echo WRONG-knob; fi
+( chain stops
+	ensure_file "$T/notadir/f" 644 x
+	echo WRONG-chain
+)
+EOF
+assert_lacks "primitives: report failure inside conditions and chains" "WRONG"
+assert_count "primitives: only the chain's failure is recorded" "pave: FAIL" 1
+
+# An unreadable file is a failure where every later step would succeed, so only the guard can stop it.
+# (Root reads anything, so this case only means something for other users.)
+if [ "$EUID" -ne 0 ]; then
+	printf 'secret\n' >"$T/unreadable"
+	chmod 000 "$T/unreadable"
+	converge <<EOF
+if ensure_file "$T/unreadable" 000 x; then echo WRONG-unreadable; fi
+if ensure_block "$T/unreadable" top b x; then echo WRONG-unreadable; fi
+EOF
+	assert_lacks "primitives: unreadable file fails, even in a condition" "WRONG"
+	assert_has "primitives: unreadable file explained" "pave: error: $T/unreadable is not readable"
+	chmod 600 "$T/unreadable"
+	assert_eq "primitives: unreadable file untouched" secret "$(<"$T/unreadable")"
+fi
+
+verdict
diff --git a/tests/run.test b/tests/run.test
new file mode 100755
index 0000000..e57a767
--- /dev/null
+++ b/tests/run.test
@@ -0,0 +1,171 @@
+#!/bin/bash
+## Tests for `pave run`: phase and host file selection, failure semantics, chains, reports.
+
+# shellcheck source=tests/testlib.bash
+source "${BASH_SOURCE[0]%/*}/testlib.bash"
+
+# ---- file selection: common file, then this host's file, and no other host's ----
+
+G="$scratch/select"
+goals "$G/manual.bash" <<<'echo common-ran'
+goals "$G/boot.bash" <<<'echo boot-ran'
+goals "$G/hosts/testhost/manual.bash" <<<'echo host-ran'
+goals "$G/hosts/otherhost/manual.bash" <<<'echo otherhost-ran'
+pv run "$G" manual
+assert_eq "selection: exit 0" 0 "$rc"
+assert_eq "selection: common then host, nothing else" $'common-ran\nhost-ran' "$out"
+
+# ---- top-level failures are recorded and the file carries on ----
+
+G="$scratch/toplevel"
+goals "$G/manual.bash" <<'EOF'
+false
+echo after-first
+x=$(exit 3)
+echo after-cmdsubst
+false
+EOF
+pv run "$G" manual
+assert_eq "toplevel: exit 7" 7 "$rc"
+assert_has "toplevel: failure located" "pave: FAIL: exit 1 at ./manual.bash:1"
+assert_has "toplevel: carried on after failure" "after-first"
+assert_has "toplevel: cmdsubst failure located" "pave: FAIL: exit 3 at ./manual.bash:3"
+assert_has "toplevel: carried on after cmdsubst" "after-cmdsubst"
+assert_has "toplevel: last-line failure located" "pave: FAIL: exit 1 at ./manual.bash:5"
+assert_count "toplevel: each failure recorded once" "pave: FAIL" 3
+assert_has "toplevel: summary counts" "pave: manual: 0 changed, 3 failed"
+
+# ---- chains stop at their first failure; the file carries on after them ----
+
+G="$scratch/chains"
+goals "$G/manual.bash" <<'EOF'
+( chain first
+	echo first-a
+	false
+	echo first-b-SHOULD-NOT-RUN
+)
+echo between
+helper() {
+	echo helper-a
+	false
+	echo helper-b-SHOULD-NOT-RUN
+}
+( chain viafunc
+	helper
+	echo viafunc-SHOULD-NOT-RUN
+)
+inner() {
+	( chain inner
+		false
+	)
+}
+( chain outer
+	inner
+	echo outer-SHOULD-NOT-RUN
+)
+( chain ok
+	echo ok-a
+	true
+	echo ok-b
+)
+EOF
+pv run "$G" manual
+assert_eq "chains: exit 7" 7 "$rc"
+assert_lacks "chains: nothing after a failure ran" "SHOULD-NOT-RUN"
+assert_has "chains: failure named and located" "pave: FAIL: chain first: exit 1 at ./manual.bash:3"
+assert_has "chains: file carried on" "between"
+assert_has "chains: failure inside a called function stops the chain" "helper-a"
+assert_has "chains: nested failure recorded at the innermost chain" "pave: FAIL: chain inner: exit 1"
+assert_count "chains: each failed chain recorded once" "pave: FAIL" 3
+assert_has "chains: a chain that doesn't fail runs to the end" "ok-b"
+
+# ---- chain outside a subshell is refused, and doesn't switch on errexit ----
+
+G="$scratch/badchain"
+goals "$G/manual.bash" <<'EOF'
+chain oops
+false
+echo still-running
+( chain )
+EOF
+pv run "$G" manual
+assert_eq "bad chain: exit 7" 7 "$rc"
+assert_has "bad chain: refusal explained" "chain oops: not inside ( ... )"
+assert_has "bad chain: refusal recorded" "pave: FAIL: exit 1 at ./manual.bash:1"
+assert_has "bad chain: errexit stayed off" "still-running"
+assert_has "bad chain: nameless chain refused" "usage: ( chain NAME ... )"
+
+# ---- whole-file aborts: unset variables, syntax errors ----
+
+G="$scratch/aborts"
+goals "$G/manual.bash" <<'EOF'
+echo before-unset
+echo "$NO_SUCH_VARIABLE"
+echo after-unset-SHOULD-NOT-RUN
+EOF
+goals "$G/hosts/testhost/manual.bash" <<'EOF'
+echo syntax-SHOULD-NOT-RUN
+if then fi
+EOF
+pv run "$G" manual
+assert_eq "aborts: exit 7" 7 "$rc"
+assert_has "aborts: ran up to the unset variable" "before-unset"
+assert_lacks "aborts: nothing ran after an abort, or in a file with a syntax error" "SHOULD-NOT-RUN"
+assert_has "aborts: unset variable aborts the file" "pave: FAIL: ./manual.bash: aborted"
+assert_has "aborts: syntax error skips the file" "pave: FAIL: ./hosts/testhost/manual.bash: syntax error"
+
+# ---- usage errors, and misnamed phase files ----
+
+pv run "$scratch/select" sesion
+assert_eq "unknown phase argument: exit 4" 4 "$rc"
+assert_has "unknown phase argument: lists phases" "boot session login manual"
+pv run "$scratch/no-such-dir" manual
+assert_eq "missing goals dir: exit 5" 5 "$rc"
+
+G="$scratch/misnamed"
+goals "$G/manual.bash" <<<'echo manual-ran'
+goals "$G/hosts/testhost/sesion.bash" <<<'true'
+pv run "$G" manual
+assert_eq "misnamed phase file: exit 6" 6 "$rc"
+assert_has "misnamed phase file: named" "pave: FAIL: ./hosts/testhost/sesion.bash: not named for a phase"
+assert_has "misnamed phase file: goals still ran" "manual-ran"
+
+# ---- the environment goal files run in ----
+
+G="$scratch/env"
+goals "$G/manual.bash" <<'EOF'
+echo "cwd=$PWD"
+if read -r line; then echo stdin-had-data; else echo stdin-empty; fi
+EOF
+out=$(echo surprise | "$PAVE" run "$G" manual 2>&1)
+assert_has "env: cwd is the goals dir" "cwd=$G"
+assert_has "env: stdin is empty" "stdin-empty"
+
+# ---- reports, and quiet convergence ----
+
+G="$scratch/quiet"
+T="$scratch/quiet-target"
+goals "$G/manual.bash" <<EOF
+ensure_file "$T" 644 hello
+EOF
+pv run "$G" manual
+assert_has "report: first run reports the change" "pave: changed: created $T"
+assert_has "report: first run prints a summary" "pave: manual: 1 changed, 0 failed"
+pv run "$G" manual
+assert_eq "report: converged run exits 0" 0 "$rc"
+assert_eq "report: converged run prints nothing" "" "$out"
+report="$HOME/.local/state/pave/manual.report"
+assert "report: written under the XDG state dir" test -f "$report"
+assert_eq "report: holds the summary even when quiet" \
+	"pave: manual: 0 changed, 0 failed (goals: $G, host: testhost)" "$(<"$report")"
+assert "report: no scratch files left behind" \
+	test "$(cd "${report%/*}" && printf '%s ' .[!.]* *)" = ".[!.]* manual.report "
+
+XDG_STATE_HOME="$scratch/state" "$PAVE" run "$G" manual
+assert "report: XDG_STATE_HOME honored" test -f "$scratch/state/pave/manual.report"
+
+pv run "$scratch/select" session
+assert_eq "no goal files for a phase: exit 0" 0 "$rc"
+assert_eq "no goal files for a phase: quiet" "" "$out"
+
+verdict
diff --git a/tests/testlib.bash b/tests/testlib.bash
new file mode 100644
index 0000000..bf758b0
--- /dev/null
+++ b/tests/testlib.bash
@@ -0,0 +1,88 @@
+## testlib.bash: shared scaffolding for pave's tests.
+##
+## Each test file sources this, then builds goal files in a scratch dir
+## and runs the real `bin/pave` against them.
+## HOME and HOSTNAME point into the scratch dir, so the real machine is never touched.
+
+set -euo pipefail
+
+PAVE=$(cd -P -- "${BASH_SOURCE[0]%/*}/.." && pwd)/bin/pave
+
+scratch=$(mktemp -d)
+trap 'rm -rf "$scratch"' EXIT
+export HOME="$scratch/home"
+export HOSTNAME=testhost
+unset XDG_STATE_HOME XDG_CONFIG_HOME
+mkdir -p "$HOME"
+
+fails=0
+
+## pv runs pave, capturing its combined output and exit status.
+##
+## - @param $1... arguments to pave.
+## - @state out the combined stdout and stderr.
+## - @state rc the exit status.
+# shellcheck disable=SC2034 # rc is for the test files to read.
+pv() {
+	rc=0
+	out=$("$PAVE" "$@" 2>&1) || rc=$?
+}
+
+## exact prints a file's content followed by a '.', so a command substitution can't hide trailing newlines.
+exact() {
+	cat -- "$1"
+	printf .
+}
+
+## goals writes a goal file, creating its directory.
+##
+## - @param $1 path of the goal file.
+## - @stdin the file's content.
+goals() {
+	mkdir -p -- "${1%/*}"
+	cat >"$1"
+}
+
+_failed() {
+	>&2 printf 'FAIL: %s\n' "$1"
+	fails=$((fails + 1))
+}
+
+## assert checks that a command succeeds.
+assert() {
+	local label=$1
+	shift
+	"$@" || _failed "$label"
+}
+
+## assert_eq checks two strings are equal.
+assert_eq() {
+	[ "$2" = "$3" ] || _failed "$1 (want '$2', got '$3')"
+}
+
+## assert_has checks that the last pv output contains a line with the given text.
+assert_has() {
+	grep -qF -- "$2" <<<"$out" || _failed "$1 (no '$2' in output)"
+}
+
+## assert_lacks checks that the last pv output doesn't contain the given text.
+assert_lacks() {
+	! grep -qF -- "$2" <<<"$out" || _failed "$1 (unwanted '$2' in output)"
+}
+
+## assert_count checks how many lines of the last pv output contain the given text.
+assert_count() {
+	local n
+	n=$(grep -cF -- "$2" <<<"$out" || :)
+	assert_eq "$1" "$3" "$n"
+}
+
+## verdict reports the outcome and exits accordingly.
+verdict() {
+	local name=${BASH_SOURCE[1]##*/}
+	if [ "$fails" -ne 0 ]; then
+		>&2 echo "$name: $fails case(s) failed."
+		exit 1
+	fi
+	echo "$name: all cases passed."
+}