adm

Rendered by adm export --generic from the public repository, so it describes no particular machine. Per-box values appear as the placeholders the runbooks are written with — $ADM_ROOT, ${BOX_NAME} — because that is the form you would write a new one in. A document rendered for a specific box substitutes them instead, and is private for that reason.

Reflects commit 59ab245, with a clean working tree.

Creating a machine from this document

This is the generic half of a two-part repository: the runbooks are the product, and each machine keeps its own configuration in a private fork. Nothing here describes a particular box, so it is the thing to start from rather than the thing to restore.

Create a machine from these runbooks.

Start with a stock Arch install you can reach over SSH, as root or a
user who can sudo, with working network and a usable mirror list.

  1. Upgrade, and install the interpreter. adm is Python and cannot
     install its own interpreter, so this comes before everything;
     Arch’s base does not include it.
       sudo pacman -Sy --needed --noconfirm archlinux-keyring
       sudo pacman -Syu --needed --noconfirm python git

     Both lines, in that order, and -Syu rather than -S. Arch has no
     supported partial-upgrade path: against databases that have
     already been synced, installing one package links it against
     libraries newer than the ones on disk. On an image that has sat
     for months that fails outright — today’s git wants libgcc and
     libstdc++, which were split out of gcc-libs, and pacman refuses
     the conflicting files rather than break the system. The keyring
     goes first because packages may now be signed by keys the image
     has never seen, and a full upgrade would abort on signatures
     before it could fix itself. 000 states this rule and sequences
     the same two steps; this is the same rule applied before there
     is a repository to run it from.

     Expect a large upgrade on an old image, possibly including the
     kernel. If it replaces the kernel, reboot before continuing:
     000’s reboot-needed check will say so, but the modules for a
     running kernel are gone once its package is replaced.

  2. Clone this repository to /opt/admin. It is the generic half:
     every runbook, no machine’s configuration.
       sudo git clone https://github.com/EnigmaCurry/adm /opt/admin
       sudo chown -R "$(id -un)" /opt/admin

  3. Name the box. This writes hosts/<box>/<box>.env with the values
     adm must read before anything can decrypt, and tells you which
     further keys the runbooks will require.
       cd /opt/admin && ./bin/adm adopt <box>

  4. Supply those keys, yourself, at a terminal:
       ./bin/adm env-fill
     It refuses without one on purpose. A value passed on a command
     line is in the shell history and in /proc, and in an agent’s
     context if an agent is driving.

  5. Run the runbooks in order. Each block’s check runs beside it,
     and the first failure stops everything.
       ./bin/adm run 000 010 015 020 030 040

  6. Decide what else this box is for. adm list shows the optional
     runbooks and what each is for; adm enable adds one.

One step in there needs a person and cannot be automated away: 020
generates an ssh key on the box and its public half has to be
authorised on your forge before the repo can be pushed. The runbook
prints the URL and stops until it is done.

Read 000 before running it. Every block says why it exists, and the
ones that would be wrong for your machine are easier to find now
than after they have run.

A box that already exists has a document of its own, rendered the same way with its values filled in and its secrets carried as ciphertext. That one restores a machine; this one starts one.

  1. adm — Arch Declarative Markdown
    1. The project and the box
    2. The box
    3. Usage
    4. Layout
    5. Getting started
    6. Honest limitations
    7. Prior art and lineage
  2. Provisioning the machine
    1. What the runbooks require of the machine
    2. Create the template
    3. Set the CPU type, which the script does not
    4. A template ages
    5. Clone a machine from it
    6. Cloning for the cold-boot test
  3. Conventions for this repo
    1. The rule
    2. Writing a runbook
    3. Block syntax
    4. Idempotence is mandatory
    5. Verify blocks should assert, not narrate
    6. Fact collectors must be deterministic
    7. Finishing a session
    8. Secrets
  4. Decisions
    1. 001 — Arch Linux, not NixOS
    2. 002 — btrfs snapshots, not ZFS
    3. 003 — Backups live at the hypervisor layer
    4. 004 — A shareable repo with secrets kept in it
    5. 005 — Two tiers of key: one operator, one per box
    6. 006 — The root filesystem stays top-level; no @, no true rollback
    7. 007 — The generic half becomes a public repo; machines are private forks
  5. 000 — Bootstrap
    1. Assumes
    2. Per-box values
    3. Which runbooks this box runs
    4. Adopting a box
    5. Requirement: vCPU capability
    6. Boot environment
    7. Order matters: keyring, then full upgrade, then everything else
    8. Reboot if the kernel was replaced
    9. Host identity
    10. Admin tooling
    11. Initialise the repo
    12. Git commit identity
    13. Put adm on PATH
    14. Record the resulting state
    15. Next
  6. 010 — The admin user
    1. Why not just use root
    2. Identity and privilege
    3. Passwordless sudo
    4. Inbound SSH keys
    5. Root keeps no SSH identity of its own
    6. Hand the repo to admin
    7. The pivot
  7. 015 — Secrets in the repo, encrypted
    1. Why SOPS and age
    2. What is and is not encrypted
    3. No comments in the env file
    4. The key
    5. Two tiers of key: operator and per-box
    6. Keep the env file encrypted
    7. Git hooks: refuse to commit or push secrets in the clear
    8. Prove a round trip
    9. What this does and does not protect
  8. 020 — Off-box remote for this repo
    1. Why a per-repo deploy key, owned by admin
    2. Pin the forge's host key
    3. SSH config for admin
    4. The git remote
    5. The manual step: authorise the deploy key
    6. The forge API token
    7. Push
    8. Upstream: where the runbooks come from
  9. 030 — Disable cloud-init
    1. Why
    2. Why this runbook is numbered after 010 and 020
    3. Preconditions: what would make disabling unsafe
    4. Disable it
    5. What was deliberately not done
    6. Confirming it stuck
  10. 040 — btrfs snapshots
    1. What this gives you, precisely
    2. What this does NOT give you — read this before relying on it
    3. Why this is numbered after 010
    4. Preconditions: root must be btrfs, and the admin user must exist
    5. Packages
    6. The root snapper config
    7. Retention policy
    8. Timers
    9. The pacman hooks
    10. adm brackets every invocation
    11. End-to-end proof
    12. Using it
    13. Next, if this proves useful
  11. 045 — User homes are subvolumes of their own
    1. How the exclusion works
    2. Who is in scope
    3. Order matters: subvolume before account
    4. What this does not do
    5. Proving it rather than asserting it
    6. Using it
  12. 050 — Boot into snapshots (grub-btrfs)
    1. What this is for, and what it is not
    2. Read this before relying on it: selection needs the console
    3. Preconditions
    4. The package
    5. Make a booted snapshot usable
    6. A menu you can actually catch
    7. Generate the menu
    8. The regeneration daemon
    9. Assert the snapshot entries would actually boot a snapshot
    10. Guard against the grub-reboot trap
    11. Using it
  13. 060 — Development packages
    1. Why these, and why a table
    2. An editor for sops
  14. 065 — System utilities
    1. Why btop
    2. The package
    3. A wrapper for compatibility
    4. Next
  15. 070 — Agent harness
    1. Blocked: this VM's CPU is too old for the agent
    2. The agent
    3. Secrets reach the agent through the environment, never the disk
    4. Lock the agent to one provider
    5. Prove the gate resolved, not merely that it was written
    6. This repo's conventions, as a skill
    7. Smoke test — two turns, not one
    8. Running it
  16. 080 — The rendered document
    1. What goes in, and what is held back
    2. Why one file
    3. The renderer
    4. Rendering it
    5. The page is also the repository
    6. Colouring the commands
    7. Finding your way around
    8. Publishing it
    9. Two documents, two audiences
    10. The box's own secrets, as ciphertext

adm — Arch Declarative Markdown

There are two questions you should be able to answer about any machine you run: how do I rebuild it, and why is it configured this way? On an Arch Linux box that has been alive for a year, both answers are usually "I don't remember." State accumulates imperatively, one pacman -S at a time, and the reasoning behind it was never written down anywhere.

The answer, always, is to put something declarative over a system that is not one. The two established ways differ in how far they go.

Ansible wraps. The box stays imperative underneath; a playbook describes the state you want and converges the machine toward it, run after run. That answers the first question and not the second, because a playbook records what to converge to and never why you chose it.

NixOS replaces. There is no imperative path left to drift down — the build sandbox makes the configuration the only channel through which the system can change, so one file answers both questions. That enforcement is exactly what adm does not have, and if you can use Nix it is worth more than anything here. It was considered and rejected for this box; DECISIONS.md #001 records the reasoning and what would reverse it.

adm wraps. Arch stays imperative underneath and nothing stops you reaching past it. The bet is that the wrapper can be prose: a box whose setup is contained entirely within a set of runbooks is effectively declarative anyway — the runbooks stop being a record of what happened and become the definition of what the box is. Replay them and the machine comes back.

A runbook is ordinary Markdown — this is a literate administration repo, and runbooks/ is all of it. The prose explains why; the commands sit beneath it in fenced blocks, and those are the ones that actually run:

`btop` replaces top or htop, with no runtime dependencies beyond libuv and
ncurses. The agent harness needs to watch system resources during long runs,
and `btop` makes that obvious rather than parsing text output.

```bash id=btop
sudo pacman -S --needed --noconfirm btop
```

```bash id=btop verify
pacman -Qi btop >/dev/null || { echo "btop not installed"; exit 1; }
command -v btop >/dev/null || { echo "btop not on PATH"; exit 1; }
```

That is 065 — the run block verbatim, the verify block with one of its three assertions dropped for length.

The explanation and the commands are one artifact, so they cannot drift apart. And every change carries a verify block asserting the property it was supposed to establish. That is the part that matters: the documentation can fail. adm verify runs every assertion in the repo, so a document that has quietly become fiction announces itself instead of waiting to be discovered.

That falsifiability is also what makes the box reasonable to hand to an agent. You describe an outcome; the agent reads the conventions, writes the runbook, shows you its plan, and stops. One approval, then it runs. It is not permitted to configure the machine directly — the only channel it has is the one that documents itself, so the write-up is a side effect of the work rather than a chore after it. See 070 for the harness and the approval gate.

The honest limit: containment is a convention here, not an invariant. Nothing errors when you break it — the repo just stops being a rebuild procedure and becomes a diary, which is why it has to be held deliberately; see AGENTS.md. adm facts is the counterweight, snapshotting packages, enabled units, listening sockets and /etc so that a git diff surfaces drift nobody wrote down.

The top-level claim is falsifiable too: replay onto a bare VM, finish with a clean adm verify. That test is owed, not passed — DECISIONS #001 records it as this project's acceptance test. Until it passes, this is a premise with good evidence behind it.

The project and the box

This repo is not scaffolding around some other purpose; it is the purpose. The runbooks and adm are the system under development, and rea-dev is the first box they describe — both the workbench and the first subject. The acceptance test above is owed by the thing being built, to itself.

The box name is a nod to REA — "Reverse Engineer Anything" — which is intended to run there later inside an agent harness. That is not set up yet, and no runbook describes it. The project took its own name afterwards, which is why the box and the repo no longer match — rea-dev is a machine, adm is what describes it.

The box

host rea-dev
distro Arch Linux (rolling)
origin stock Arch image, built 2026-01-15 by arch-boxes CI
admin repo /opt/admin (this directory)
adopted 2026-10-18 — see AS-FOUND.md for provenance, hardware and loose ends

Usage

adm list                  # every runbook and the blocks it will run
adm run 000-bootstrap.md  # run that runbook's blocks, verifying as it goes
adm verify                # run every verify block in every runbook
adm replay                # run every runbook in order — i.e. rebuild
adm facts                 # snapshot system state into facts/, commit if changed
adm log                   # the run journal

adm run and adm replay stop at the first failure. adm verify runs everything and reports all failures, so it is safe to use as a health check.

Add -n / --dry-run to see what would execute without running it. To work around a block that is failing for a reason you already understand — a pending reboot, say — use adm run <runbook> --only <block-id> (repeatable) to run a subset, or -k / --keep-going to continue past failures.

Which flags each of those three takes, exactly:

adm run     -n --only -k --no-snapshot
adm replay  -n -k --no-snapshot
adm verify  -n

That is a fence rather than a sentence because 000 checks it against the parser. The prose version said -k could be added "to any of them" while adm replay -k was an error, and nothing noticed: a claim about a program, written beside the program, in a form the program cannot read.

Layout

The repo splits in two: the generic half is the product, and each box gets a namespace directory for its own configuration and state. One repo can describe several machines; adm picks the box directory automatically when there is only one, and by $ADM_BOX or hostname when there are more.

path what
runbooks/NNN-topic.md generic — the runbooks, applied in filename order
bin/adm generic — the runner, stdlib Python 3, no dependencies
AGENTS.md generic — conventions, for any agent and for humans
CLAUDE.md generic — a symlink to AGENTS.md, for harnesses that look for that name
DECISIONS.md generic — why things are the way they are, and what would reopen each choice
.sops.yaml generic — encryption rules for the per-box env files
hooks/pre-commit generic — refuses to commit an unencrypted env file or any private key
hosts/.gitignore generic — ignores every box, so no machine state is published by default. A fork appends !/<box>/ to adopt its own
hosts/<box>/<box>.env per-box values, secrets encrypted. The only file that must differ between machines
hosts/<box>/AS-FOUND.md per-box — its state at adoption. Written once, never maintained
hosts/<box>/facts/ per-box — generated snapshots of system state, committed on change
hosts/<box>/log/journal.md per-box — append-only record of every block ever run
hosts/<box>/log/runs/ per-box — full output of each run (gitignored)

Getting started

Two different jobs, with different prerequisites. Read the one you are doing.

Both share one non-obvious step. adm finds a box by looking for <dir>/<dir>.env, picking it automatically when there is only one and otherwise matching $ADM_BOX or the hostname. A freshly provisioned box still carries the image's hostname — arch, typically — so once this repo describes more than one machine, selection by hostname fails until 000 sets the real name. Until then, pass it:

ADM_BOX=<box> adm replay

A — a new machine from barebones Arch

For standing up a box this repo does not yet describe.

Prerequisites: an Arch VM you can reach over SSH as root or as a sudo-capable user. Prefer a btrfs root: 040 requires it and refuses to run otherwise, and Arch's own arch-boxes images are btrfs already. If the box was provisioned by cloud-init, let it finish before touching anything — it is what installs root's authorized_keys, which 010 copies to the admin user, and 030 explains why it must not be disabled before then.

  1. Get the repo onto the box at /opt/admin. On a brand new machine use rsync from a workstation that already has a clone — cloning on the box itself needs a deploy key that does not exist yet:

    rsync -a --exclude '.git' ./ root@<newbox>:/opt/admin/
    
  2. Adopt the box.

    adm adopt <box>
    adm adopt <box> --from workstation   # and opt into a purpose
    

    A profile is an env preset, not a different set of runbooks: it carries the opt-in list for a purpose and records ADOPTED_FROM. templates/ holds them as <name>.preset, and a preset may contain only keys the encryption policy exempts — so it can turn 070 on but cannot carry its credentials.

    That creates hosts/<box>/, writes hosts/<box>/<box>.env with the keys that have to be readable in the clear, appends the anchored negation to hosts/.gitignore, and stages both. It then prints the keys the later runbooks will require, and which runbook requires each.

    It does not restate that key list anywhere: it reads the runbooks for the assertions they already fail on. This paragraph used to carry the list by hand and was wrong for several hours after 010 began requiring a third key, which is why the command exists and why 000's adopt-roundtrip now fails if the two ever disagree again.

    The keys it writes are readable in the clear by necessity: 010 creates the admin account before 015 generates this box's age key, so at that moment nothing on the machine could decrypt a value. .sops.yaml exempts them for that reason.

    what makes the box yours; a git add -f is not a substitute.

  3. Bootstrap and create the admin user, as root or with sudo:

    ADM_BOX=<box> python3 /opt/admin/bin/adm run 000 010
    

    000 brings the system current, sets the hostname from BOX_NAME and puts adm on PATH; 010 creates the admin account, grants passwordless sudo and hands it ownership of the repo. After this, connect as ssh admin@<box> and stop using root.

  4. Reboot if 000 says the kernel changed, then carry on. It tells you explicitly rather than rebooting for you.

  5. Set up secrets — 015 generates this box's age identity, adds it to .sops.yaml, and arms the git hooks:

    adm run 015
    

    The box key it generates is disposable — it only ever reads this box's own env file, and a rebuild can replace it. What must already exist is an operator recipient listed in operator.pub, whose private half lives on your workstation at ~/.config/sops/age/keys.txt and in your password manager. That is the one key whose loss is unrecoverable; see 015 for the two tiers.

  6. Add any secret values this box needs to its env file, then encrypt:

    sops hosts/<box>/<box>.env          # edit decrypted, saves encrypted
    adm run 015 --only encrypt-env
    
  7. Give it a remote — 020 generates a deploy key and prints it. Add it to the forge with write access, then adm run 020 finishes and pushes.

  8. Finish the rest, then check:

    adm replay && adm verify && adm facts
    

A second box is also the first real test of whether the generic/per-box split holds. Expect to find things that are still accidentally specific to rea-dev; that is useful information, and fixing it belongs in the runbooks rather than in the new box's env file.

B — reproducing a machine this repo already documents

For rebuilding a box after loss, or recreating it to prove the documentation is true. Doing this successfully is this project's acceptance test — see DECISIONS.md #001, where it is recorded as owed.

Create the replacement VM to match. Nothing inside the guest can check these until it is too late to change them cheaply, and hosts/<box>/facts/ in this repo records what the original had:

requirement why where recorded
CPU type host (or ≥ x86-64-v3) 070's agent is a Bun binary needing AVX2. Proxmox defaults to kvm64, which is v1. 000 fails early rather than late, but fixing it needs a VM stop/start — a guest reboot will not apply a CPU model change facts/system.txt
btrfs root 040 requires it and refuses otherwise. Arch's own arch-boxes images are btrfs facts/storage.txt
GRUB, on BIOS or UEFI Firmware is not the constraint — 050 only calls grub-mkconfig, never grub-install, and Arch puts grub.cfg in the same place either way. What it cannot drive is systemd-boot, which has no snapshot-boot equivalent. Also: /boot must sit on the root btrfs, so a snapshot carries its own kernel — a UEFI install that mounts the ESP at /boot breaks that facts/system.txt
cloud-init provisioning root's authorized_keys 010 copies them to the admin user. Without them you cannot log in at all, and no runbook can fix a box you cannot reach AS-FOUND.md

That last one is the real hazard: it is the only failure here that locks you out rather than failing loudly.

Prerequisites — get these in hand first. Both are discoverable halfway through a rebuild, which is the worst possible moment:

  1. Do not delete the original yet. Shut it down and build the replacement alongside it. A stopped VM costs nothing, and it turns an irreversible test into a reversible one — if the rebuild stalls, start the original and you have lost an afternoon rather than a machine. Delete it only once the replacement passes step 7.

  2. Provision barebones Arch to the requirements above, matching the original's filesystem — hosts/<box>/facts/storage.txt records what it was.

  3. Get the repo onto the box at /opt/admin, by rsync from a workstation clone. Cloning on the box needs a deploy key that does not exist yet, so rsync first and let 020 sort out the remote later.

  4. Bootstrap and create the admin user. Stop here — do not replay further yet, because the next step needs the admin user's home to exist:

    ADM_BOX=<box> python3 /opt/admin/bin/adm run 000 010
    
  5. (Optional shortcut.) If you kept the old box key, restore it before 015 runs and the rebuild proceeds without the operator step below:

    install -d -m 0700 ~/.config/sops/age
    install -m 0600 /path/to/restored/keys.txt ~/.config/sops/age/keys.txt
    

    015's age-key block only generates a key when none exists, so a restored one is left alone. This is a convenience, not a requirement.

  6. Replay, and expect it to stop twice. Both stops are the runbooks reporting something untrue, not defects:

    adm replay
    
    • At 015's sync-env-recipients, because the rebuilt box's new key is not yet a recipient of its own env file. The block prints the fix: from the operator workstation, in a clone of this repo, git pull then sops updatekeys -y hosts/<box>/<box>.env, commit and push. Then git pull on the box and re-run. Do not copy the operator key onto the box.
    • At 020's forge-authorized, until the new deploy key is authorised on the forge with write access.
  7. Reboot when 000 reports the kernel changed, then adm replay again. Every block is idempotent, so this is safe and expected.

  8. The actual test:

    adm verify
    

    It must come back completely clean. If it does not, the documentation is wrong, and that is a bug in this repo — fix the runbook, not the box.

  9. Compare against the recorded state. adm facts writes a fresh snapshot over the committed one, so git diff hosts/<box>/facts/ is a direct comparison between the rebuilt machine and the one this repo described. Expect noise only in genuinely variable things; anything structural is a reproduction gap worth a runbook change.

Honest limitations

Prior art and lineage

Read this before assuming the conventions here are ones you already know. The ideas are established; the syntax is local to this repo.

What is standard:

What is not standard — and this is the important part — is the fence syntax. CommonMark specifies nothing about execution attributes on code fences, so every tool in that list invented its own convention; see the syntax comparison across projects. So id=, verify and skip are ours. No other tool reads them, and nobody arrives already knowing them. The full rule set is five lines in AGENTS.md.

Why a bespoke runner instead of Runme

adm needs only python3, which ships in Arch's base. The stock image that became this box had no git, no make, no jq — on a bare rebuild target adm replay runs with zero bootstrapping. Runme is a Go binary you would have to get onto the box before you could follow the runbook that installs things, which is a chicken-and-egg problem on exactly the machine where it matters most. Runme also has no verify-block equivalent, which is the feature doing the real work here.

One accidental virtue: the attributes degrade gracefully. Renderers key syntax highlighting off the first info-string token, so ```bash id=foo verify still renders as a normal highlighted bash block on GitHub and elsewhere. These files stay readable as plain documentation with no tooling present at all.

Provisioning the machine

Everything else in this repository runs on a box. This runs on the hypervisor, before the box exists, and adm never touches it — there is no runbook here because there is nothing for adm to verify yet.

Written for Proxmox, because that is what this was built on. The requirements below are what the runbooks check for; any hypervisor or bare metal that meets them will do.

What the runbooks require of the machine

Each of these is enforced by a check, named so you can see what refuses and where:

requirement enforced by if missing
btrfs root 040#root-is-btrfs snapshots are unavailable; the runbook refuses rather than half-working
GRUB, with /boot on the root filesystem 050#grub-btrfs-preconditions no bootable snapshot entries
x86-64-v3 CPU (AVX2) 000#cpu-capability the agent harness in 070 spins at 100% producing nothing
python installed 000's Assumes adm cannot run at all, including the block that installs things
SSH, as root or a user who can sudo — nothing can be driven remotely

The Arch cloud image satisfies the first two as it ships. It is not an ext4 image that needs converting: the layout it produces is

sda1    1M          BIOS boot partition, for GRUB on GPT
sda2  300M vfat     ESP, automounted at /efi
sda3  ...G btrfs    root, with compress-force=zstd

which is the layout 040 and 050 were written against — and the layout rea-dev has, because it came from this image. DECISIONS.md #002 records discovering that: a migration was planned on the belief that root was ext4, and root was already btrfs.

The third requirement is the one you must add by hand. See below.

Create the template

The script is from the Proxmox chapter of book.rymcg.tech and builds templates for several distributions; only Arch is relevant here.

On the Proxmox host:

wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/proxmox/proxmox_kvm.sh
chmod a+x proxmox_kvm.sh
DISTRO=arch TEMPLATE_ID=9000 STORAGE_TYPE=local STORAGE=local-lvm ./proxmox_kvm.sh template

It downloads Arch-Linux-x86_64-cloudimg.qcow2 from mirror.pkgbuild.com, imports it, sets rootflags=compress-force=zstd and a serial console in GRUB, installs qemu-guest-agent, re-initialises the pacman keyring, upgrades, and converts the VM to a template.

Read the variables at the top of the script before running it: STORAGE_TYPE and STORAGE for a different backend, SSH_KEYS for the public key cloud-init will authorise, FILESYSTEM_SIZE, NUM_CORES, MEMORY. On NFS storage chattr +i fails during template creation and that can be ignored.

Set the CPU type, which the script does not

The script configures fourteen qm flags and --cpu is not among them, so the template inherits Proxmox's default — kvm64, which is roughly x86-64-v1: no AVX2, no AVX, not even SSE4.2 or AES-NI.

qm set 9000 --cpu host

Set it on the template so clones inherit it. Without it, 000#cpu-capability stops the run before anything is installed, which is the check working — 070's agent harness is a Bun binary that needs AVX2, and on a CPU without it the failure is not an error but a process spinning at 100% for ever. This box shipped as kvm64 and 070 was blocked until the VM's CPU type was changed.

host passes the physical CPU's features through, which also means a migration to a host with an older CPU can take features away. On a cluster, set a named model that every node supports instead.

A template ages

The script upgrades the image while building the template, so a template is current on the day it is made and not afterwards. A clone of a template built months ago is a stale Arch install with current package databases, and that combination is the one Arch handles worst: installing a single package against synced databases is a partial upgrade, which is unsupported and fails.

It is not hypothetical. The first machine built from these instructions was cloned from a nine-month-old template, and the first command in the creation directive — installing git — aborted on conflicting files, because today's git depends on libgcc and libstdc++, which have since been split out of gcc-libs, and the old gcc-libs still owned those files. Nothing was damaged; pacman refused, which is what it is for.

So either rebuild the template before cloning from it:

qm destroy 9000
DISTRO=arch TEMPLATE_ID=9000 STORAGE_TYPE=local STORAGE=local-lvm ./proxmox_kvm.sh template
qm set 9000 --cpu host

or expect the first step on the clone to be a full system upgrade, which is what the creation directive now does: archlinux-keyring first, then pacman -Syu with the packages, in one transaction. On an image that has sat for a long time that can be a hundred or more packages including the kernel, and a reboot before continuing.

Rebuilding is cheaper and gives a clone that boots ready. Upgrading is the only option for a template you cannot rebuild.

Clone a machine from it

TEMPLATE_ID=9000 VM_ID=100 VM_HOSTNAME=my-box ./proxmox_kvm.sh clone
qm start 100
VM_ID=100 ./proxmox_kvm.sh get_ip

Then continue in 000-bootstrap.md, or follow the creation directive at the top of the published document, which is the same sequence written for someone who has only that page.

Cloning for the cold-boot test

The point of a disposable clone is a box that has never been bootstrapped. Several checks in this repository can only fail there: a verify block that asserts state a later block creates passes forever on a box that was set up once, because the state is there from last time. Seven defects of that shape were found by doing this by hand, and nothing in the repo can catch the next one.

So a clone made for testing should be destroyed afterwards rather than kept and re-run — a second run on the same box is a poor test of the first.

Conventions for this repo

Read README.md first for what this repo is and how adm works.

This file is the contract for adding to it. It applies to Claude and to humans equally.

The rule

Nothing changes on this box except through a runbook. If you are about to run a command that alters the system, it goes in a runbook first, and you run it with adm run. Reaching for a bare shell to "just quickly" install or edit something is how the documentation becomes fiction.

Read-only investigation at the shell is fine and encouraged. Mutation is not.

This is not bureaucracy — it is the single load-bearing assumption of the whole repo. Arch is an imperative system, but setup contained entirely within the runbooks is effectively declarative: the runbooks become the definition of the box, and the box becomes a replayable consequence of them. That is the same property NixOS provides, obtained by discipline instead of by a build sandbox, and it is why this repo stays plain enough to read as prose. See DECISIONS.md #001.

The difference is that Nix enforces the single channel and this repo only assumes it. Break the assumption and nothing errors — the repo quietly stops being a rebuild procedure and becomes a diary. adm facts is the only detector, which is why it runs at the end of every session.

Writing a runbook

Name it runbooks/NNN-topic.md, where NNN leaves room to insert later: step by 10 within a theme, by 100 between themes. Filename order is execution order, so a runbook may depend on anything numbered below it and must not depend on anything above.

Structure:

  1. # NNN — Title
  2. A short paragraph on why this exists — the decision, the constraint, the thing that will be non-obvious in six months. This is the part that no other tool (etckeeper, Ansible) preserves. Write it for a reader who has forgotten everything.
  3. Then, per change: prose explaining it, the bash block that does it, and a verify block that proves it.

Block syntax

This syntax is local to this repo — not a standard, and not read by any other tool. Do not assume familiarity from elsewhere; see README.md for what here is established practice and what is invented.

A bash block is executed if and only if it has an id= attribute:

```bash id=install-nginx
pacman -S --needed --noconfirm nginx
```

```bash id=install-nginx verify
pacman -Qi nginx >/dev/null
systemctl is-enabled nginx
```

One block is not bash at all. Every runbook declares, directly under its purpose line, whether every box runs it:

```adm
required
```

required runs everywhere. optional runs only where the host env lists its number in OPTIONAL_RUNBOOKS. A runbook declaring neither is unclassified and adm will not run or verify it — not "required by default", because a new runbook silently running everywhere and one silently running nowhere are both wrong, and only one of them is visible. 000's runbook-classification fails on an unclassified runbook, and on an opt-in naming something that does not exist or is not optional.

An optional runbook nobody enabled is silent during a run — no warning, no skip notice — and still listed by adm list, with how to enable it. Silence is for things deliberately not in use; a misspelled opt-in is a different matter and fails loudly.

A required runbook must never depend on an optional one. The optional one may not have run, and the bug would appear only on boxes that did not opt in. Nothing enforces this: required runbooks already link to optional ones nine times, every one a cross reference rather than a dependency, so a scan would report nine false positives and teach everyone to ignore it. Telling "refers to" from "relies on" needs a person.

A bash block with no id= is illustrative and never runs — use that for "here is what the broken output looked like". Blocks in any other language (ini, toml, text, …) are never executed, so config file contents can be shown inline for readability and then written by a bash block.

Every block runs under bash with set -euo pipefail, with the repo root as cwd, and with $ADM_ROOT, $ADM_RUNBOOK, $ADM_ID in the environment. Use $ADM_ROOT rather than hardcoding /opt/admin.

Every key in <box>.env is also exported — $BOX_NAME, $ADMIN_USER, $GIT_REMOTE. Runbooks must be generic: anything true of one machine rather than of the procedure belongs in <box>.env, and anything that is history rather than procedure belongs in AS-FOUND.md. A runbook that accumulates a specific box's story stops being runnable on the next box, which is most of its value gone.

Two parts of that are checked, by 080's generic-runbooks. A box's name may not appear in an executable block — that is code, and a name in it makes the procedure wrong on the next machine; use $BOX_NAME, which adm substitutes before the block runs. Nor in a link target: a link to hosts/<box>/AS-FOUND.md resolves on one machine and is dead in every fork, so per-box files are referred to by name without a link.

Prose and illustrative blocks may name a box, and should where a concrete example reads better. Substitution reaches executable blocks only, so a placeholder written into an example reaches the published document as a placeholder: sops <box>/<box>.env is something nobody can paste. The generic source is what the export's templates carry; the rendered page is allowed to be about the machine that produced it.

Run adm list after editing to confirm the runner sees what you intended.

Idempotence is mandatory

adm replay must be safe to run on an already-configured box, because that is what makes this repo a rebuild procedure rather than a diary. Concretely:

Verify blocks should assert, not narrate

Check the property you actually care about, and exit non-zero when it does not hold. systemctl is-active foo is a good verify; systemctl status foo is not, because it succeeds regardless. Prefer checking observable behaviour (a port answers, a file has the expected mode) over checking that you ran the command (a package is installed).

Verify blocks must be read-only and fast. They run on every adm verify, including when someone is debugging a broken box.

A verify block must be able to see what it asserts. Runbooks run as admin, so an unprivileged check against a root-owned path can fail for the wrong reason — or, far worse, succeed for the wrong reason. test ! -e /root/.ssh/config passes whenever /root is unreadable, reporting absence when it has merely been denied a look. Absence assertions in particular must acquire privilege (sudo test -e ...) and should confirm the containing directory is readable first, so "not there" is distinguishable from "not visible". This exact bug shipped here and survived until a replay as admin caught it; running the runbooks as root hides the entire class.

A verify block must fail only for the reason it names. Distinguish "the property does not hold" from "I could not observe it" — these are different findings, and only one of them is a bug in the box. Empty output is not a negative result; that is the rule above, applied past the filesystem. A probe that shells out to something which starts a server, waits on a timer or touches the network will sometimes come back with nothing, and that is a retry, not a verdict. Nor should you discard stderr from the command whose failure you are diagnosing: 2>/dev/null is how a failing block produces a log holding one line of conclusion and no cause. This shipped here as well — agent-gate reported the agent's permission gate missing, twice, because opencode debug agents returns nothing while another opencode is mid-start, which during adm verify is the normal path rather than an edge case. The determinism demanded of fact collectors below applies here too, and for a sharper reason: a noisy collector produces a diff you learn to ignore, while a flaky verify raises a false alarm about the one property you built it to watch.

Let a service settle before asserting it runs. systemctl is-active immediately after systemctl enable --now can return active for a unit that has already exited, because systemd has not reaped it yet. This shipped here: grub-btrfsd died after 11 ms from a missing optional dependency and the run block reported success. Pause, then re-check, and assert the main PID is actually alive — not merely that the unit was once started.

Fact collectors must be deterministic

hosts/<box>/facts/ earns its keep only if a diff means something changed. A collector whose output varies while the system does not produces fake diffs, and a few reboots' worth of those teaches you to skim past the one file you should be reading — which is strictly worse than not collecting it.

So: run a collector twice in a row and require byte-identical output. Known offenders, all of which bit this repo already:

When you must drop something transient, record the stable part of it rather than dropping the subject entirely. And prefer normalising to excluding: an omitted collector hides real drift just as effectively as a noisy one buries it.

Finishing a session

adm verify          # everything still true?
adm facts           # snapshot state; commits only if something changed
git -C /opt/admin status

If adm facts shows drift you did not make through a runbook, do not just commit it — find out what changed, then either write the runbook that explains it or revert it. Committing unexplained drift silently turns this repo back into an audit log.

Secrets

Do not put credentials in runbooks. Document where a secret comes from and how to install it ("the deploy key lives in <password manager>; install to /root/.ssh/id_ed25519 mode 0600"), and let the verify block check the file's mode and fingerprint rather than its contents.

Decisions

Append-only. One entry per decision that shaped this box, newest last.

A runbook records how something was done. This file records why that approach — the forks in the road, what was rejected, and the conditions under which a decision should be reopened.

Format. Entries use the field names of an Architecture Decision Record (Nygard, 2011) in its Markdown form, MADR: Status, Context and problem statement, Decision drivers, Considered options, Decision outcome, Consequences, Confirmation. Standard names are used so the structure is recognisable rather than invented here — see README.md.

One local addition: Reopen this if. MADR's Status field can be set to superseded after the fact, but nothing in the template says in advance what should trigger that. Writing the trigger down while the reasoning is fresh is the difference between a decision record and a diary entry.


001 — Arch Linux, not NixOS

Context and problem statement

This box is a stock Arch image (KVM guest, legacy BIOS boot, btrfs root) being adopted as a service host. The workstation administering it runs NixOS with a declarative disko disk layout, so NixOS was the obvious default and was seriously considered, including the in-place conversion paths.

The question: should the box be converted to NixOS to get declarative, reproducible configuration, or stay on Arch and obtain that property another way?

Decision drivers

Considered options

  1. Stay on Arch, contain all setup in literate runbooks.
  2. nixos-anywhere + disko — kexec into an installer, wipe, declaratively install.
  3. nixos-infect — install Nix on the running system, swap the bootloader in place.
  4. Side-by-side NixOS install, Arch kept bootable as fallback.
  5. Provision a fresh NixOS VM and migrate the service onto it.

Decision outcome

Option 1 — stay on Arch, contain all setup in the runbooks.

The governing insight, in the operator's words:

NixOS is declarative, but it fights our runbook plainness. Arch is imperative, but if we contain setup to the runbook, it becomes effectively declarative, yet plain.

That is the whole design. Declarativeness is not a property of the OS — it is a property of the discipline of a single channel for change. Nix enforces that channel with a type system and a build sandbox, and charges real complexity for it. A runbook enforces it by convention and charges almost nothing. Convention is weaker, but it is legible.

Supporting reasons, roughly in order of weight:

  1. The conversion was the risk, not the OS. An in-place conversion is a bootloader, initrd and root-filesystem transplant performed simultaneously — and this box boots legacy BIOS, so there is one MBR stage and no efibootmgr escape hatch. Steady-state comparison favours NixOS; the comparison that mattered was steady-state NixOS minus an unrecoverable transplant.
  2. Scale mismatch. Nix's value scales with configuration complexity and with the number of hosts and people needing identical environments. Here: one host, one operator, a system whose entire state fits on a page.
  3. Nix's guarantee doesn't cover what breaks service hosts. It makes the system closure reproducible. It does not manage service data, disk layout (without re-running disko destructively), or secrets. Outages come from the database, the certs and the full disk, not from package drift.
  4. Rollback is already better here. Booting an older NixOS generation reverts the closure, not on-disk state — old code against a migrated database is arguably worse than no rollback. This box already runs btrfs, where snapshots can revert system and data together.
  5. Incident ergonomics. pacman -S strace during an outage, standard FHS paths, and third-party prebuilt binaries that run without patchelf or an FHS wrapper.

Pros and cons of the other options

option assessment
2. nixos-anywhere + disko Technically the cleanest conversion, and the one to use with console access. Rejected here because kexec is a point of no return whose worst case is an ISO reinstall — precisely what the no-console driver forbids.
3. nixos-infect Targets Debian/Ubuntu cloud images; Arch untested. Inherits the existing imperative partitioning, giving up the declarative disk layout that is half the reason to switch. Strictly worse than option 2 given an empty 250 G disk.
4. Side-by-side install Safest conversion — worst case is a power reset back into working Arch. Still a BIOS GRUB chainload transplant, and it leaves the disk layout non-declarative.
5. Fresh NixOS VM, migrate Not rejected on merit — deferred. Strictly better than converting this box: clean disko layout, UEFI if wanted, one mental model shared with the administering workstation, and a tested host before anything depends on it. This is the path to take if this decision is reopened.

Consequences

Good

Bad

Confirmation

This decision holds only while all three of these are true. They are separate properties and should not be conflated — the second is cheap and says little, the third is the one that matters.

  1. Containment — every change to the box flows through a runbook. Checked by adm facts reporting no drift that isn't attributable to a runbook, at the end of every session. Ongoing.
  2. Idempotence — adm replay on an already-configured box is a no-op. Checked by a full replay finishing green with no duplicated state. Demonstrated 2026-10-18T06:19:04–06:19:10Z: all eight blocks of 000-bootstrap.md ran in order against a configured box, every block and verify green, no spurious commit from repo-init. See hosts/<box>/log/journal.md.
  3. Reproduction — adm replay onto a fresh Arch VM builds this box, finishing with a clean adm verify. Never run. Still owed.

Check 2 proves the runbooks are re-runnable; only check 3 proves they are sufficient. Passing 2 is weak evidence for 3, because a block can silently depend on state the stock image doesn't have and still succeed here. Until 3 passes, reproducibility is a claim rather than a demonstrated property — NixOS would have made it true by construction, and we chose to owe a test instead. Pay the debt.

Reopen this if


002 — btrfs snapshots, not ZFS

Context and problem statement

We wanted snapshot capability: cheap rollback for development workflows, and a way to undo catastrophes like a mistaken rm. The initial proposal was to migrate the root filesystem to ZFS, in place, on a single disk, from what was believed to be an ext4 root — using a RAM-based SSH-accessible rescue environment to shrink the filesystem offline.

The premise was mistaken: root was already btrfs, with compress-force=zstd:3, not ext4:

/dev/sda3 btrfs rw,compress-force=zstd:3,discard=async,space_cache=v2,subvolid=5

So the requested capability was already present and unused. That reframed the question from "how do we migrate to ZFS" to "is ZFS worth migrating to, given snapshots are already available".

Decision drivers

Considered options

  1. Use btrfs snapshots, already available.
  2. ZFS root, single disk: shrink in place, kexec rescue environment, migrate.
  3. ZFS root on a second virtual disk.
  4. ZFS for data only, keeping btrfs root.

Decision outcome

Option 1. Implemented in runbooks/040-btrfs-snapshots.md: snapper with a tuned retention policy and timers, plus snap-pac for automatic pre/post snapshots around every pacman transaction.

The decisive argument is not that btrfs is better than ZFS — it is not, feature for feature. It is that ZFS is not in Arch's repositories. It ships via AUR or the third-party archzfs repo as an out-of-tree DKMS module, and OpenZFS supports new kernels on a lag. On a rolling release that makes every pacman -Syu a chance to install a kernel ZFS cannot build against — and with ZFS as root, that means an unbootable box.

The migration risk was explicitly accepted, and would have been acceptable; this is a different thing. It is a recurring risk, on every upgrade, forever, on a machine with no console. Avoiding it would require pinning to linux-lts with IgnorePkg, reintroducing precisely the staleness that 000 exists to prevent.

Two further points settled it:

Pros and cons of the other options

option assessment
2. ZFS root, single disk The only option that needed a rescue environment, and the only one ending in an unfixable partition geometry. Note the irony: the offline-shrink apparatus was required by ext4. btrfs shrinks online, so most of that machinery was unnecessary even on its own terms.
3. ZFS root, second disk Much better than option 2 — clean full-disk vdev, no shrinking, no stripe, no orphaned space. Still carries the DKMS-versus-rolling-kernel risk on the root filesystem. Viable if a second disk is attached.
4. ZFS data-only, btrfs root The best ZFS option, and not rejected on merit. Datasets, properties, send/recv and zvols, with a version mismatch costing a mounted pool rather than the ability to boot. The path to take if ZFS features are wanted later.

Consequences

Good

Bad

Confirmation

On converting an ext4 root to btrfs

Not needed here, and not implemented, but assessed because the repo aims to be generic. It is materially safer than ext4 → ZFS would have been: btrfs-convert works in place with no partition surgery, keeps the same PARTUUID, and has a real undo in btrfs-convert --rollback (the original metadata is preserved in an ext2_saved subvolume). And btrfs is in-tree, so the recurring kernel-coupling risk disappears entirely.

What remains genuinely hard:

Preferred alternatives, in order: provision btrfs in the first place (Arch's own arch-boxes images are btrfs, which is where this box came from); or copy-based migration — create btrfs on free space or a second disk, rsync across, switch root — which avoids btrfs-convert altogether and yields a clean @ layout that in-place conversion cannot produce.

Any conversion procedure added later must be labelled untested until it has been exercised against a throwaway ext4 VM. We have no ext4 box here, so shipping it unlabelled would mean the first person to run it is the test.

Reopen this if


003 — Backups live at the hypervisor layer

Context and problem statement

#002 set up btrfs snapshots and listed the absence of off-box replication as the largest remaining gap, with btrbk or btrfs send as the implied future work. That framing was wrong: backup is already handled, one layer down.

Decision

Backups are taken at the hypervisor layer. In-guest btrfs snapshots are for convenience only — fast, fine-grained undo — and carry no durability guarantee.

Decision drivers

Consequences

Good

Bad / to keep in mind

Confirmation

Not confirmable from the guest, by construction. The honest check is a periodic restore test at the hypervisor layer — a backup that has never been restored is a hypothesis. That check is out of scope for this repo and is recorded here so the gap in coverage is visible rather than assumed away.

Reopen this if


004 — A shareable repo with secrets kept in it

Context and problem statement

The purpose of this box became explicit: it is a development workstation for building an LLM-bootstrapped, self-documenting workflow that produces a declarative configuration system for Arch Linux. The repo is not scaffolding around the purpose — it is the product, and the box is both workbench and first subject. (rea-dev is named for REA, "Reverse Engineer Anything", intended to run here later in an agent harness; not set up, and no runbook describes it.)

A product is meant to be shared. That collides with the fact that configuring a real machine involves real secrets, and with the earlier design in which every runbook simply hardcoded this box's values.

Decision

Three changes, together:

  1. Split generic from per-box. The product — runbooks/, bin/adm, AGENTS.md, DECISIONS.md, .sops.yaml — lives at the root. Each machine gets a namespace directory holding only its own configuration and state: hosts/<box>/<box>.env, hosts/<box>/AS-FOUND.md, hosts/<box>/facts/, hosts/<box>/log/. One repo can describe several boxes; adm selects the directory automatically when there is one, by $ADM_BOX or hostname when there are more.
  2. Nothing host-specific in a runbook. Every value that is true of one machine rather than of the procedure comes from <box>.env. Host and port are derived from $GIT_REMOTE rather than stored again, so there is one source of truth.
  3. Secrets in the repo, encrypted with SOPS + age. The key lives on the box, outside the repo, at the location SOPS looks by default. Superseded by #005: there are now two tiers, and the key that can read everything deliberately does not live on any managed box.

Pros and cons of the encryption options

option assessment
SOPS + age Chosen. Encrypts values, leaving keys readable, so git diff still shows which setting changed. age keys are one line — no keyring, no agent, no expiry. Both in official repos.
age alone Whole-file. An opaque blob has no reviewable history, which forfeits the audit trail that makes this repo worth having.
git-crypt Same whole-file diff problem, and upstream has been quiet for years.
plain GPG Works; heavy key management, and agent behaviour on a headless box is a recurring source of mystery failures.

The deciding factor was reviewable diffs, not cryptographic strength — all four are adequate there.

Consequences

Good

Bad

Confirmation

The caveat: this repo is not yet publishable

Encrypting <box>.env was necessary and is nowhere near sufficient.

  1. Git history already contains the plaintext. GIT_REMOTE was committed in clear earlier the same day, and the forge URL appeared in prose in several files before being parameterised. Encrypting a file does not retract what is already in history.
  2. hosts/<box>/facts/ leaks the network. facts/network.txt records the box's address, gateway, DNS server and search domain; facts/users.txt records accounts; facts/etc-manifest.txt lists every file in /etc. These are committed in plain text by design, because their value is the reviewable diff — encrypting them would destroy the drift detection they exist for.

So the honest status: encryption protects against accidental exposure and makes the repo shareable with people who are already trusted. It does not make this repo publishable. Publication needs one of:

Not decided. Recorded so that "the secrets are encrypted" is never mistaken for "this can be made public".

Amendment 2026-10-20: publication is now a real goal

The reopen condition below has fired. #007 makes the generic half a public repository, which is what "a product is meant to be shared" was always reaching for. What follows corrects this entry where later work changed the facts, and leaves the rest standing.

The premise holds; only its status changes. The three decisions above are unaffected. The generic/per-box split and "nothing host-specific in a runbook" come out strengthened — the second turned out to have no enforcement at all, and a block in 015 was carrying one box's blob shas inside a generic runbook for weeks before anyone measured it (21529bd). That is an argument for the rule, not against it.

The route is still open, and this entry's advice is still the safer half of it. Taking the fresh-repo route "rather than trying to launder this history" was written before hooks/scan-history existed. That tool now proves no secret survives a filtered history, which weakens one objection — but not the other. Measured today: the forge's hostname is absent from the working tree and present in 4 commits touching generic paths, in prose written before the remote was parameterised. A path-filter keeps those commits, and scan-history will not flag them, because it checks env files for unencrypted values and a hostname in a paragraph is neither. So the choice is a real one:

Deliberately still not decided here; it belongs to #12's execution, where the filtered result can be inspected before anything is pushed.

The second objection is gone. hosts/<box>/facts/ cannot leak into a public repo under either route: a path filter keeps only generic paths, and hosts/ is not one of them. Nothing needs excluding, encrypting or scrubbing — the concern was correct for rewriting this repo in place, which is no longer the plan.

Reopen this if


005 — Two tiers of key: one operator, one per box

Context and problem statement

#004 put secrets in the repo encrypted with SOPS + age, with each box holding its own key. That left a question it did not answer: can one stored key decrypt every machine's configuration, including machines that do not exist yet?

The answer was "accidentally yes, for now". 015 took the union of every recipient in a single creation_rules entry, and SOPS consults the recipient list only at encryption time and never re-keys existing files. The result was chronology-dependent access: each box could read the env of every box created after it, and none of the ones created before. Neither isolation nor a master key — an artefact of the order the machines happened to be built in.

Worse, the only universal recipient was rea-dev's own box key. The thing that must never be lost was sitting in plaintext on a disposable VM, and a compromise of that VM would hand over the key to every future machine's secrets too.

Decision

Two kinds of identity, with different lifetimes and different reach:

identity lives can decrypt
operator the administering workstation at ~/.config/sops/age/keys.txt, plus the password manager. Never on a managed box. every box, including boxes not yet created
per-box the box itself only that box's own env file

.sops.yaml is generated from operator.pub plus each hosts/<box>/<box>.age.pub, with one creation_rules entry per box naming the operator recipients and that box's key. There is deliberately no catch-all: an unmatched .env makes SOPS refuse rather than encrypt to the wrong set.

Decision drivers

Considered options

  1. Union of all recipients in one rule. The status quo being replaced.
  2. Per-path rules, operator plus that box. Chosen.
  3. Per-path rules, operator only — boxes hold no key at all.
  4. An external secret manager.

Pros and cons of the other options

option assessment
1. Union Looked fine and was not. Chronology-dependent access, and the universal recipient was a box key. Rejected on both counts.
3. Operator-only Simplest and most secure: a box could encrypt (recipients are public) but never read. Rejected because adm replay must decrypt $GIT_REMOTE unattended, and an operator-only design makes every replay an interactive operation.
4. External manager Right answer at a different scale. Adds a network dependency and a service to run for a handful of values, and the box would still need a credential to reach it — moving the problem rather than solving it.

Consequences

Good

Bad

Confirmation

Reopen this if


006 — The root filesystem stays top-level; no @, no true rollback

Context and problem statement

040 now brackets every adm invocation in a snapshot pair, and adm checkpoint / adm restore give named, permanent restore points. All of it is file-level: snapper undochange computes a diff between two snapshots and copies files back.

What that cannot be is a rollback in the sense people mean when they say the word — the VM-style "put the machine back as it was". That is snapper rollback, which creates two snapshots, points btrfs's default subvolume at a writable copy of the target, and reboots into it. It requires the root filesystem to be a nested subvolume, conventionally @, so that something outside it can be redirected. This box's root is subvolid=5 — FS_TREE, the filesystem itself. There is nothing to point elsewhere.

So: migrate to @ and gain true rollback, or stay as we are?

Decision

Root stays top-level. No @ migration. Rollback on this box means file-level restore, and whole-machine recovery stays at the hypervisor layer where #003 already put it.

Decision drivers

Consequences

Good

Bad

Confirmation

Reopen this if

If either happens, the ordering is not negotiable: /home, and anything like /var/lib/docker, must become their own subvolumes before the @ migration, not after. Rolling back @ takes everything inside @ with it, and a snapshot cannot exclude a directory — only a separate subvolume is excluded, because snapshots do not recurse into child subvolumes. Do these in the wrong order and the first rollback reverts a user's home directory. That is why distributions shipping rollback hand you @ and @home together from the installer; it is not a stylistic choice.


007 — The generic half becomes a public repo; machines are private forks

Context and problem statement

#004 split generic from per-box but left publication undecided, and its premise — "a product is meant to be shared" — stayed aspirational. The collision became concrete once this repo went private: anything under hosts/<box>/ is then readable only by repo members, which is right, but if every machine repo is private the generic half has no home. N copies, quietly diverging, and the runbooks stop being the definition and become per-machine forks of one. That is the single failure this project cannot absorb.

The export changed one half of the problem in the meantime. A published document now carries its own sources and bin/adm, verified byte-for-byte on every render, so the generic half can travel in one file to anyone holding a link. That solved distribution. It did nothing for canonicity — being able to hand someone a copy is not the same as there being an original.

Decision

The generic half becomes a public repository. Each machine repo is a private fork of it, and pulls fixes with git merge upstream/master.

Considered and rejected

option why not
Fork Chosen. One working tree, so adm verify still covers everything the box owns; git merge upstream/master needs no tooling; conflicts appear exactly where a machine edited a generic file, which is the signal you want.
Subtree Would also keep one tree, and was the preferred answer while the generic half was assumed to live inside each machine repo. A fork makes it unnecessary — the whole tree already is the generic half plus one directory.
Submodule Breaks the single-tree property adm verify depends on, and makes the generic half something the box references rather than owns.
No central home; drift found by comparing published documents Distribution without canonicity. Each machine could prove what it has; none could say what is correct.

Consequences

Good

Bad

Confirmation

Reopen this if

What publication actually cost

The generic half was published as one commit, not as filtered history, and the reason is worth recording because the first attempt was wrong and public for ten minutes.

Filtering seemed safe: drop hosts/, operator.pub, *.env, confirm no per-box path remains, confirm scan-history is clean. All of that passed, and the result still carried one machine's network configuration, its account list and a 24 KB inventory of its /etc. Per-box files had lived at three spellings over the project's life — the repo root, then rea-dev/, then hosts/rea-dev/ — and a filter written against the current layout keeps the oldest. scan-history did not object because those files hold no secret by its definition: they are facts, which is exactly why they are committed.

The verification was wrong in the same way the filter was, which is the part worth remembering. The paths checked for were the paths excluded, so the check confirmed the filter had done what it was told rather than that the result was clean. The question that would have caught it is which top-level paths have ever existed in this history — answerable in one command, and not asked.

A single commit has no such surface. The cost is the commit messages, which carried most of the reasoning; this file is where that reasoning lives instead, which is an argument for keeping it current rather than for keeping the history.

000 — Bootstrap

Required — every box runs this.

Take a freshly provisioned Arch box to a documented baseline with this repo installed and running.

This runbook is generic: it should work on any stock Arch machine, and nothing in it is specific to the box it happens to be running on. Values that differ per machine live in <box>.env. For what one particular box looked like when it was adopted — provenance, hardware, loose ends — see AS-FOUND.md; that archaeology deliberately does not live here.

Examples below use rea-dev for concreteness. Substitute your own.

Assumes

That last point makes the runbook mildly self-referential: it sets up the repo that contains it. On a real rebuild you get the repo onto the box first — clone it from the remote configured in 020, or rsync it from a workstation — and only then replay. Every block is idempotent, so replaying against an already-bootstrapped box is a no-op.

Per-box values

adm exports every key in <box>.env into the environment of every block, so runbooks refer to $BOX_NAME rather than hardcoding a name. Standing up another machine means editing that one file.

verify: box-env
test -n "${BOX_NAME:-}" || { echo "BOX_NAME unset -- is <box>.env present?"; exit 1; }
echo "BOX_NAME=$BOX_NAME  ADMIN_USER=${ADMIN_USER:-unset}"

Which runbooks this box runs

Every runbook declares itself, in a fenced adm block directly under its purpose line:

```adm
required
```

Required runs on every box. Optional runs only where the host env opts in by number, through OPTIONAL_RUNBOOKS:

OPTIONAL_RUNBOOKS=050 070 080

A runbook declaring neither is unclassified, and adm will not run or verify it. That is deliberate rather than defaulting either way: a new runbook that silently ran everywhere and one that silently ran nowhere are both failures, and only one of them is visible. adm list names it in red.

The flag is in the file rather than the filename. Filenames are referenced fifty-nine times across this repo and anchor every link in the export, so a rename to flip one runbook would rewrite its inbound links each time.

Silence is asymmetric, and that is the point

An optional runbook nobody opted into produces no output at all during a run — not a warning, not a skip notice. With enough of them, per-run notices about things deliberately not in use train people to stop reading adm run.

An opt-in naming a runbook that does not exist must fail loudly. Silence there would hide a typo that disables a runbook the operator believes is running, which is the dangerous direction. That check lives in a verify block rather than only in adm run, so a misspelling fails on every adm verify instead of waiting for a replay.

Not opting in also means not verified. 080's export-renderer asserts cmark-gfm is installed; on a box that never enabled 080, that is a failure reported for something deliberately absent. So adm verify skips them too, which sharpens what DECISIONS #001's acceptance test means: a clean verify covers every required runbook plus the optional ones this box chose.

One invariant this adds, which nothing enforces

A required runbook must never depend on an optional one, because the optional one may not have run — and the bug would appear only on the boxes that did not opt in, which is the worst possible distribution.

Nothing checks it, and the obvious check does not work. Nine required runbooks already link to optional ones, every link a legitimate cross reference rather than a dependency, so scanning for references would report nine false positives and teach everyone to ignore it. Distinguishing "refers to" from "relies on" needs a human.

verify: runbook-classification
cd "$ADM_ROOT"
state() {
  awk '/^```adm$/{f=1;next} f&&/^```$/{exit} f{print}' "$1" | tr -d '[:space:]'
}

bad=0 req=0 opt=0
for f in runbooks/*.md; do
  case "$(state "$f")" in
    required) req=$((req+1)) ;;
    optional) opt=$((opt+1)) ;;
    "")  echo "$f declares neither required nor optional"; bad=1 ;;
    *)   echo "$f declares an unknown state: $(state "$f")"; bad=1 ;;
  esac
done
test "$bad" = 0 || { echo "adm will not run or verify an unclassified runbook"; exit 1; }

# The asymmetry: an opt-in naming something absent is a typo that silently
# disables a runbook the operator believes is running.
missing=""
for n in ${OPTIONAL_RUNBOOKS:-}; do
  hit="$(ls runbooks/ | grep -c "^$n-" || true)"
  if [ "$hit" = 0 ]; then
    missing="$missing $n(no such runbook)"
  elif [ "$(state "runbooks/$(ls runbooks/ | grep "^$n-" | head -1)")" != optional ]; then
    missing="$missing $n(not optional)"
  fi
done
test -z "$missing" \
  || { echo "OPTIONAL_RUNBOOKS names:$missing"
       echo "an opt-in must name a runbook that exists and declares itself optional"
       exit 1; }

echo "$req required, $opt optional, ${OPTIONAL_RUNBOOKS:-none} opted in"

Every block a runbook declares has to be a block adm can see

A fence is closed by the first line that looks like a closing fence, and parsing then resumes after it. Several blocks here write whole documents from heredocs, and those documents contain fenced code of their own — so a stray fence inside one truncates the block it is in and can swallow the blocks that follow. Nothing complains. adm run and adm verify simply have fewer blocks to work with than the file appears to declare, and both report success over what is left.

That is not hypothetical and it was not a stranger's mistake. A section was once inserted into 070 by anchoring on a heading that occurs twice — once in that runbook's prose, once inside the skill document the runbook writes. The fenced block went into the heredoc, agent-skill lost its closing fence and its own verify, and adm verify 070 reported all blocks ok while running eleven of thirteen.

verify: runbook-blocks-parse
cd "$ADM_ROOT"
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U
import io, os, re, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)

# Column 0 only. An indented fence is an illustration inside prose, which is
# not a declaration and is not meant to run; a buried one is at column 0,
# because it was written to be a block and ended up inside another.
fence = re.compile(r"^```(?:bash|sh) id=(\S+)", re.M)
bad = []
for path in sorted(mod.all_runbook_paths()):
    declared = fence.findall(io.open(path, encoding="utf-8").read())
    parsed = [b.attrs["id"] for b in mod.parse_runbook(path)]
    for ident in declared:
        if ident not in parsed:
            bad.append("%s#%s" % (os.path.basename(path), ident))
if bad:
    print("declared at the start of a line, but not visible to adm:")
    for b in bad:
        print("  " + b)
    print()
    print("Each is inside another block's body -- almost certainly a heredoc")
    print("that writes markdown. adm would skip it silently.")
    raise SystemExit(1)
print("every declared block parses, across %d runbooks"
      % len(mod.all_runbook_paths()))
PY

Adopting a box

A box is adopted by adm adopt <box>, which creates hosts/<box>/, writes the env file with the keys that must be readable before anything can decrypt, and appends the anchored negation to hosts/.gitignore.

It was prose until now, and prose is the one form this repo cannot check. It drifted exactly as you would expect: the README said two keys were enough to bootstrap for several hours after this runbook began requiring a third, so the documented path for a new machine was broken and nothing could notice.

The command does not restate the key list. It reads the runbooks for ${KEY:?...} assertions — the same text the runbooks themselves fail on — and writes the ones it can supply, then reports the rest with the runbook that will demand each. The instructions cannot disagree with the runbooks because there is only one list.

Two details are load-bearing, both found by getting them wrong:

Adopting as something

adm adopt <box> --from workstation

A profile is an env preset, not a separate set of runbooks. It carries the opt-in list for a purpose plus whatever non-secret defaults those runbooks want, and adm adopt instantiates it into hosts/<box>/<box>.env, recording ADOPTED_FROM so a later reader can tell what was chosen. Separate runbook sets per profile would break "the repo is one document in order" and bring back the dependency problem; a preset selects, it does not fork.

The opt-in list for a purpose is itself knowledge. "A workstation wants user home subvolumes, boot-into-snapshots, the agent harness and the export" is a judgement someone made once. Without presets every new box re-derives it by reading the runbook directory and guessing.

Presets are named <name>.preset, not <name>.env, deliberately. 015's commit hook refuses any *.env that is not SOPS-encrypted — the rule that stops a machine's secrets reaching a remote in clear — and a preset must be plaintext, because a fork has no key for it. Exempting templates/ from the scanner would have been the easy fix and the wrong one: the file is not a box env, so it should not take the name. The scanner's key-material check still applies to it, as it does to every blob.

A preset may contain only keys the encryption policy exempts. That rule is not invented for presets: .sops.yaml's unencrypted_regex is this repo's definition of what is not a secret, and a preset is a plaintext file shipped to every fork. So 070's LLM credentials and 080's publish keys cannot be in one — the preset turns those runbooks on, and the operator supplies the values with sops afterwards. A key-shaped value in a shipped preset is a trap adm env-check could never catch, because the name would be perfectly well formed.

Profiles are exclusive for now. --from workstation,docker-host raises ordering and conflict questions immediately, and there is no evidence yet about what they should be.

verify: profile-presets
cd "$ADM_ROOT"
test -d templates || { echo "no templates/ directory"; exit 0; }
shopt -s nullglob
presets=(templates/*.preset)
test "${#presets[@]}" -gt 0 || { echo "templates/ holds no presets"; exit 0; }

unenc="$(awk -F\' '/^[[:space:]]*unencrypted_regex:/ {print $2; exit}' .sops.yaml)"
test -n "$unenc" || { echo "no unencrypted_regex in .sops.yaml"; exit 1; }

state() {
  awk '/^```adm$/{f=1;next} f&&/^```$/{exit} f{print}' "$1" | tr -d '[:space:]'
}

bad=0
for p in "${presets[@]}"; do
  while IFS= read -r line; do
    case "$line" in ''|'#'*) continue ;; esac
    [[ "$line" == *=* ]] || continue
    k="${line%%=*}"
    # The whole rule: a preset is plaintext and shipped, so it may carry only
    # what the policy already says is not a secret.
    grep -qE "$unenc" <<<"$k" \
      || { echo "$p sets $k, which the policy does not exempt"; bad=1; }
    if [ "$k" = OPTIONAL_RUNBOOKS ]; then
      for n in ${line#*=}; do
        f="$(ls runbooks/ 2>/dev/null | grep "^$n-" | head -1)"
        if [ -z "$f" ]; then
          echo "$p opts into $n, which is not a runbook here"; bad=1
        elif [ "$(state "runbooks/$f")" != optional ]; then
          echo "$p opts into $n, which is not optional"; bad=1
        fi
      done
    fi
  done < "$p"
done
test "$bad" = 0 || exit 1
echo "${#presets[@]} preset(s), none carrying a secret, all opt-ins real"

The README's flag table, against the parser

README.md lists which flags adm run, adm replay and adm verify take. It said -k worked on "any of them" while adm replay -k was an error — a claim about a program, written beside the program, in a form the program could not read. It is a fence now, and this compares it with --help.

Both directions fail. A flag the README claims and the parser rejects is the original defect; a flag the parser accepts and the README omits is how the first one started.

verify: readme-flags
cd "$ADM_ROOT"
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import io, re, subprocess

text = io.open("README.md", encoding="utf-8").read()
# Located by content, not position: every line is "adm <cmd> <flags...>", so
# adding a paragraph above the fence is not a silent breakage.
claims = {}
for block in re.findall(r"^```\n(.*?)^```\n", text, re.M | re.S):
    rows = [l.split() for l in block.strip().split("\n") if l.strip()]
    if (rows and all(len(r) >= 3 and r[0] == "adm" for r in rows)
            and all(f.startswith("-") for r in rows for f in r[2:])):
        claims = {r[1]: set(r[2:]) for r in rows}
        break
if not claims:
    raise SystemExit("README has no fenced flag table. It documented these in "
                     "prose once\nand the prose was wrong, which is why the "
                     "fence exists.")

# The usage line, not the options list. argparse prints every flag there in
# brackets, one spelling each -- the same spelling the README uses -- so it
# needs no regex over help text. Reading the options list instead is what the
# first version of this did, and a mis-escaped character class turned "-n,
# --dry-run" into a flag called --d.
bad = []
for cmd, want in sorted(claims.items()):
    out = subprocess.run(["./bin/adm", cmd, "--help"],
                         capture_output=True, text=True).stdout
    usage = out.split("options:")[0]
    have = set()
    for tok in re.findall(r"\[([^\]]+)\]", usage):
        first = tok.split()[0]
        if first.startswith("-"):
            have.add(first)
    have.discard("-h")
    for f in sorted(want - have):
        bad.append("%s does not accept %s, but README lists it" % (cmd, f))
    for f in sorted(have - want):
        bad.append("%s accepts %s, which README does not list" % (cmd, f))

if bad:
    for b in bad:
        print("  " + b)
    raise SystemExit(1)
print("README's flag table matches the parser for %d command(s): %s"
      % (len(claims), ", ".join(sorted(claims))))
PY

Requirement: vCPU capability

Checked first, because the cost of discovering it late is the whole run. The agent harness in 070 is a Bun-compiled binary that needs AVX2, and on a CPU without it the failure is not a clean error — it spins at 100% for ever producing no output, which reads like a hang or a network fault. Finding that out after a full system upgrade, a user, a remote and six more runbooks is a waste of an hour and a reboot.

Hypervisors make this easy to get wrong. Proxmox defaults new VMs to the kvm64 model, which is roughly x86-64-v1 — no AVX, no AVX2, not even SSE4.2 or AES-NI. This box shipped that way, and 070 was blocked until the VM's CPU type was changed to host.

The check reports the x86-64 microarchitecture level rather than listing flags, because that is the vocabulary hypervisors and toolchains use. REQUIRE_CPU_LEVEL defaults to 3 and can be lowered in <box>.env for a box that genuinely does not need an agent — a deliberate exception rather than a silent one.

It is exempt from encryption, which it has to be. This block runs before 015 has generated anything that could decrypt a value, so an encrypted REQUIRE_CPU_LEVEL is an escape hatch that cannot be opened at the only moment anyone reaches for it: a fresh box, refusing to start, on a CPU the operator has already decided to accept. It was encrypted until a bootstrap on such a box went looking for the hatch and found it locked.

verify: cpu-capability
have() { grep -qw "$1" /proc/cpuinfo; }
# Every flag must be present. Note: a bare `for ... || break` cannot express
# this -- a loop's exit status is the status of `break`, which is zero, so the
# first version of this check reported v2 on a CPU with no SSE4.2 at all.
all() { local f; for f in "$@"; do have "$f" || return 1; done; return 0; }

level=1
if all cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3; then level=2; fi
if (( level == 2 )) && all avx avx2 bmi1 bmi2 f16c fma movbe xsave; then level=3; fi
if (( level == 3 )) && all avx512f avx512bw avx512cd avx512dq avx512vl; then level=4; fi

want="${REQUIRE_CPU_LEVEL:-3}"
model="$(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')"
echo "cpu: $model -> x86-64-v$level (require v$want)"

if (( level < want )); then
  echo
  echo "This vCPU is below the level this stack requires."
  echo "In Proxmox: set the VM's CPU type to 'host' (or x86-64-v$want) and reboot."
  echo "'kvm64', the default, is v1 and lacks AVX2, AES-NI and much else."
  echo "A CPU type change needs a full stop/start, not a warm reboot from"
  echo "inside the guest -- the vCPU model is set when the VM process starts."
  echo "If this box genuinely needs no agent harness, set REQUIRE_CPU_LEVEL"
  echo "in <box>.env to record that as a decision."
  exit 1
fi

Boot environment

Reported early so a rebuild on different firmware is visible before it causes confusion, and because 050 has a real requirement here.

Firmware is not the constraint. BIOS and UEFI both work; what matters is which bootloader and where /boot lives. 050 drives GRUB, so a box using systemd-boot gets everything except boot-into-snapshot. And 040/050 both depend on /boot being inside the root filesystem, so a snapshot carries the kernel and initramfs that matched it — a UEFI install that mounts the ESP at /boot instead of /efi breaks that, on otherwise identical hardware.

This block reports rather than dictates; the hard requirements live in the runbooks that actually need them.

verify: boot-environment
if [[ -d /sys/firmware/efi ]]; then fw="UEFI"; else fw="BIOS (legacy)"; fi

loader="unknown"
if command -v grub-mkconfig >/dev/null && [[ -d /boot/grub ]]; then
  loader="grub"
elif sudo bootctl is-installed >/dev/null 2>&1; then
  loader="systemd-boot"
fi

# /boot inside the root filesystem is what lets a snapshot carry its kernel.
if boot_src="$(findmnt -no SOURCE /boot 2>/dev/null)"; then
  root_src="$(findmnt -no SOURCE /)"
  if [[ "$boot_src" == "$root_src" ]]; then boot_loc="same filesystem as /"
  else boot_loc="SEPARATE ($boot_src)"; fi
else
  boot_loc="inside the root filesystem"
fi

echo "firmware: $fw   bootloader: $loader   /boot: $boot_loc"
test "$loader" != "unknown" || {
  echo "no recognised bootloader found; 050 cannot manage this box"; exit 1; }

Order matters: keyring, then full upgrade, then everything else

This is the one sequencing rule in the runbook, and getting it wrong on a stale image breaks the system.

Arch is a rolling release with no supported partial-upgrade path. Syncing the package databases and then installing a single package (pacman -Sy foo) links that package against libraries newer than the ones on disk. Never -Sy without -u.

The keyring is the one documented exception. On an image that has sat for months, some packages are now signed by keys it has never seen, and a full upgrade aborts on signature errors before it can fix itself. Pulling archlinux-keyring ahead of the upgrade is the sanctioned way out.

runs: keyring
sudo pacman -Sy --needed --noconfirm archlinux-keyring
verify: keyring
pacman -Qi archlinux-keyring | awk -F': +' '/^Version/ {print "keyring", $2}'
sudo pacman-key --list-keys >/dev/null

Now the full upgrade. On a long-stale image this is the step most likely to need a human: read any .pacnew notices and intervention warnings it prints rather than assuming --noconfirm handled everything.

runs: system-upgrade
sudo pacman -Syu --noconfirm
verify: system-upgrade
if pacman -Qu; then
  echo "ERROR: upgrades still pending"
  exit 1
fi
echo "no pending upgrades"

Reboot if the kernel was replaced

When an upgrade replaces the kernel, the running kernel's module tree is deleted from disk, and loading any module not already resident fails until reboot. The symptom is confusing and arbitrary, so check for it explicitly.

Deliberately not paired with an automatic reboot: rebooting is a decision, not a side effect of a verify. Expect this to fail after any kernel upgrade and to pass once rebooted.

verify: reboot-needed
running="$(uname -r)"
installed="$(pacman -Q linux | awk '{print $2}')"
if [[ -d "/usr/lib/modules/$running" ]]; then
  echo "running kernel $running still has its module tree; no reboot required"
else
  echo "REBOOT REQUIRED: running $running, installed $installed,"
  echo "and /usr/lib/modules/$running is gone."
  exit 1
fi

To reboot, deliberately: sudo systemctl reboot.

Host identity

Stock Arch images ship a placeholder hostname — arch, typically — and an SSH client alias on your workstation does not change what the box calls itself. The mismatch is invisible until something that reports its own hostname confuses you: logs, mail, certificates, monitoring, prompts.

Setting it is cheap, but it is a rename, so it is a separate decision from the rest of the bootstrap. If the box is already named correctly this block is a no-op.

runs: hostname
if [[ "$(hostnamectl --static)" != "$BOX_NAME" ]]; then
  sudo hostnamectl set-hostname "$BOX_NAME"
fi
hostnamectl --static
verify: hostname
test "$(hostnamectl --static)" = "$BOX_NAME" \
  || { echo "static hostname is '$(hostnamectl --static)', expected '$BOX_NAME'"; exit 1; }
echo "hostname is $BOX_NAME"

Admin tooling

Kept deliberately small — just what this repo needs to function. Editors, language toolchains and services each earn their own runbook, so the reason for a package being present is written down next to it rather than accumulating in one anonymous list.

package why
git version control for this repo
rsync moving files on and off the box
jq JSON wrangling in later runbooks
sops decrypt the secret values in <box>.env — see 015
age the encryption backend SOPS uses here
python bin/adm is written in it, so nothing here runs without it

python is in that list and used to be explained as absent, with the reason that it comes with Arch's base. It does not. base is a package of 38 dependencies and none of them is Python; there is no base group at all:

$ pacman -Qi base | grep -c python
0

Every box this repo has ever run on had Python because a Proxmox cloud-init image installs it as a dependency — of the service 030 exists to switch off. On this box it was never an explicitly installed package, which means pacman -Rns cloud-init, a reasonable thing to do once it has been disabled, was a plausible route to removing the interpreter the whole repository runs on.

Installing it here does not make it available — it has to be there already, since adm is what runs this block. It makes it owned: an explicit install reason, so nothing takes it away as an unused dependency, and a line in a table saying why it is on the box.

The conclusion the old note drew is still right, for a better reason. adm is written against the standard library, so it needs the interpreter and nothing else — no virtualenv, no packages, no bootstrapping. That is why the bootstrap-critical keys in <box>.env are left unencrypted: adm has to read them before this block has run.

runs: admin-packages
sudo pacman -S --needed --noconfirm git rsync jq sops age python
# --needed skips a package already present, but asking for it by name is what
# marks it explicitly installed. On a box where Python arrived as a
# dependency, that is the whole point of naming it here, so make the install
# reason certain rather than hoping -S set it.
sudo pacman -D --asexplicit python >/dev/null
verify: admin-packages
for p in git rsync jq sops age-keygen python3; do
  command -v "$p" >/dev/null || { echo "missing: $p"; exit 1; }
done
# Explicitly installed, not merely present. `command -v python3` succeeded on
# this box for a year while the package was only a transitive dependency of
# cloud-init, which is exactly the state that makes it removable without
# anything objecting. Presence is what adm needs today; ownership is what
# keeps it here.
pacman -Qe python >/dev/null 2>&1 \
  || { echo "python is installed but not owned: it is a dependency of"
       echo "something else, so removing that something takes bin/adm's"
       echo "interpreter with it. Fix: sudo pacman -D --asexplicit python"
       exit 1; }
echo "git $(git --version | awk '{print $3}'), rsync, jq, sops $(sops --version --disable-version-check 2>/dev/null | awk '{print $2}'), age, python $(python3 -V | awk '{print $2}') present and owned"

Initialise the repo

The HEAD guard matters: this block makes the initial commit only. Without it, every replay would sweep whatever happened to be uncommitted into a commit labelled "initial commit" — which is precisely what it did on its first revision here, mislabelling an unrelated change. Committing ongoing work is a human decision with a human message.

runs: repo-init
cd "$ADM_ROOT"
if [[ ! -d .git ]]; then
  git init -q -b master
fi
# The commit identity, set on the repository rather than on a user. See below
# for why; it is here because the commit this block makes is the first thing
# that needs it, and because an identity for a repository cannot be written
# before the repository exists.
git config --local user.name  "$BOX_NAME admin"
git config --local user.email "admin@$BOX_NAME"
# This one is genuinely per-user: it decides the branch name of repos not yet
# created, so it has nowhere else to live. Belt and braces either way -- the
# init above names master explicitly -- but an operator typing `git init` by
# hand gets the same answer.
git config --global init.defaultBranch master
if ! git rev-parse --verify -q HEAD >/dev/null; then
  git add -A
  git commit -q -m "admin: initial commit of runbooks and adm runner"
fi
git log --oneline -1
verify: repo-init
cd "$ADM_ROOT"
test -d .git || { echo "no git repo at $ADM_ROOT"; exit 1; }
# That master is the trunk, not that you are standing on it. Work happens on
# branches -- `adm land` requires one -- and a check that fails for being
# somewhere else is testing where you stand rather than what it claims to.
git rev-parse --verify --quiet master >/dev/null \
  || { echo "no master branch; master is this repo's trunk"; exit 1; }
echo "master: $(git log --oneline -1 master)  (HEAD on $(git rev-parse --abbrev-ref HEAD))"

Checking that adoption held

adm adopt ran before this runbook did, and this is where its work is checked rather than where it happens. The check lives here, among the system steps, because it asks git whether the box directory is visible -- which needs the repository the block above just created. It sat in the preflight group until a fresh box proved it could not: on a machine bootstrapping for the first time, it ran fourteen blocks before anything had made a repository for it to ask. Every box after the first one hid the problem, because the repo was already there from last time.

verify: adopt-roundtrip
cd "$ADM_ROOT"
box=admadopttest
test ! -e "hosts/$box" || { echo "hosts/$box exists; clean it up first"; exit 1; }

cleanup() {
  rm -rf "hosts/$box" "hosts/${box}2"
  if [ -f hosts/.gitignore ]; then
    grep -v -E -x -- "!/${box}2?/" hosts/.gitignore > hosts/.gitignore.tmp \
      && mv hosts/.gitignore.tmp hosts/.gitignore
  fi
  git rm -q --cached -r --ignore-unmatch "hosts/$box" "hosts/${box}2" >/dev/null 2>&1 || true
  git restore --staged hosts/.gitignore >/dev/null 2>&1 || true
}
trap cleanup EXIT

adm adopt "$box" >/dev/null || { echo "adm adopt failed"; exit 1; }
# Two box directories now exist, so adm must be told which one it is running
# as. Naming it rather than relying on the hostname also keeps this honest on
# a machine whose hostname does not match its box.
export ADM_BOX="$BOX_NAME"
env="hosts/$box/$box.env"
test -f "$env" || { echo "$env not created"; exit 1; }
test "$(grep -c "^BOX_NAME=$box\$" "$env")" = 1 \
  || { echo "BOX_NAME is not $box"; exit 1; }

# Required keys, read from inside bash blocks only. Grepping the raw markdown
# instead found KEY, from the paragraph above that describes the pattern:
# prose is not code, and adm does not treat it as code either.
keys() {
  python3 - "$@" <<'PY'
import io, re, sys
for path in sys.argv[1:]:
    fence = False
    for line in io.open(path, encoding="utf-8"):
        if line.startswith("```"):
            fence = not fence
            continue
        if fence:
            # Comment lines skipped, exactly as _required_env_keys skips them.
            # This check compares adopt against that derivation, so the two
            # have to agree about what counts -- and when they agreed by
            # sharing a defect, a key invented by a comment passed both.
            if line.lstrip().startswith("#"):
                continue
            for k in re.findall(r"\$\{([A-Z][A-Z0-9_]*):\?", line):
                if not k.startswith("ADM_"):
                    print(k, path)
PY
}

# A key required before anything on the box can decrypt has to be in this
# file: there is no other way to supply it, because the age key does not
# exist yet. Which runbook that boundary falls at is derived, not named -- the
# one defining the sops-rules block.
#
# Excluding this runbook is not a detail. A check that greps the runbooks is
# itself a runbook, so whatever it searches for, it finds in its own text.
# Three drafts of this block failed that way in a row: on ".sops.yaml", on the
# word KEY in the paragraph above, and on the very block id below. $ADM_RUNBOOK
# is what adm exports for exactly this -- the file the running block lives in.
sops_rb="$(grep -l 'id=sops-rules' runbooks/*.md \
           | grep -vF -- "$ADM_RUNBOOK" | sort | head -1)"
test -n "$sops_rb" \
  || { echo "no runbook defines sops-rules, so the point where encryption"
       echo "becomes available cannot be located"; exit 1; }

early=""
for rb in $(ls runbooks/*.md | sort); do
  [ "$rb" \< "$sops_rb" ] || break
  early="$early $rb"
done
test -n "$early" || { echo "no runbooks sort before $sops_rb"; exit 1; }

# An earlier draft of this listed ADMIN_USER, ADMIN_UID and BOX_NAME, which
# restates the list it exists to derive -- the exact drift adopt was written
# to make impossible, reproduced inside its own test. It passed while 010
# required a key adopt had never heard of.
missing=""
while read -r k rb; do
  grep -q "^$k=" "$env" || missing="$missing $k($(basename "$rb"))"
done < <(keys $early | sort -u)
test -z "$missing" \
  || { echo "adopt wrote no value for keys needed before encryption exists:"
       echo " $missing"; exit 1; }

# Every other required key must at least be accounted for: written, or named
# in what adopt tells the operator to add later. One it has never heard of is
# the drift this exists to catch.
report="$(adm adopt "${box}2" 2>&1)" || { echo "$report"; exit 1; }
unknown=""
while read -r k _; do
  grep -q "^$k=" "$env" && continue
  grep -q "^  $k " <<<"$report" || unknown="$unknown $k"
done < <(keys $(ls runbooks/*.md | grep -vF -- "$ADM_RUNBOOK") | sort -u -k1,1)
test -z "$unknown" || { echo "adopt neither wrote nor reported:$unknown"; exit 1; }

# The trap: the directory must be visible to git, or facts written into it
# later are invisible rather than merely untracked.
! git check-ignore -q "hosts/$box/" \
  || { echo "hosts/$box is ignored; the negation did not take"; exit 1; }
printf 'probe\n' > "hosts/$box/probe.txt"
git status --porcelain -- "hosts/$box/probe.txt" | grep -q . \
  || { echo "a file inside hosts/$box is invisible to git status"; exit 1; }

grep -q -x -- "!/$box/" hosts/.gitignore \
  || { echo "hosts/.gitignore has no anchored negation for $box"; exit 1; }

adm adopt "$box" >/dev/null 2>&1 \
  && { echo "a second adopt of the same box succeeded"; exit 1; }

cleanup
trap - EXIT
test ! -e "hosts/$box" || { echo "cleanup left hosts/$box behind"; exit 1; }
grep -q -x -- "!/$box/" hosts/.gitignore \
  && { echo "cleanup left the negation in hosts/.gitignore"; exit 1; }
echo "adopt supplies every key the pre-encryption runbooks require, leaves the box visible to git, and refuses a second time"

Git commit identity

Commits made on the box need an author. This is the box's identity, not a person's: a commit authored here means "done on this machine", which is exactly what you want to see when reading the history later.

So it is set on the repository, not on a user, and the difference is not cosmetic. It used to be git config --global, with this runbook claiming that root got it during bootstrap and admin got it afterwards and both ended up with the same values. Nothing made that true. admin does not exist until 010, so on a fresh box only root was ever configured, and the first commit attempted after the pivot failed with Author identity unknown — on a box whose entire record is its commits.

Repo-scoped is also the only answer that stays right as the box gains users. MACHINE_USERS may be real people with their own git identities; a --system setting would rename their commits, and a --global one has to be repeated for each of them and remembered for the next. The repository is the thing the identity describes, so the repository is where it goes.

What repo-scoped gives up is that .git/config is not cloned, so a rebuild that clones from the remote arrives without an identity. That is why repo-init sets it on every run rather than only when it creates the repo: a second run on a cloned repo does nothing else, and sets this.

verify: git-identity
cd "$ADM_ROOT"
# --local only, deliberately. `git config user.name` would answer from the
# global config too, so it would pass for any user who happened to have one
# set -- which is exactly the state that looked fine for root and failed for
# admin. Reading only the repository's own config asks the real question.
test "$(git config --local user.name)"  = "$BOX_NAME admin" \
  || { echo "the repo has no commit identity of its own; run repo-init"; exit 1; }
test "$(git config --local user.email)" = "admin@$BOX_NAME" \
  || { echo "the repo's commit email is not admin@$BOX_NAME"; exit 1; }
test "$(git config --global init.defaultBranch)" = "master"
# Whoever is running this must actually be able to commit. The identity being
# present and the identity being usable are different claims, and it was the
# second one that broke.
git -C "$ADM_ROOT" var GIT_AUTHOR_IDENT >/dev/null \
  || { echo "git cannot resolve an author for $(id -un) here"; exit 1; }
echo "commit identity: $(git config --local user.name) <$(git config --local user.email)>  (as $(id -un))"

Put adm on PATH

runs: adm-on-path
sudo ln -sfn "$ADM_ROOT/bin/adm" /usr/local/bin/adm
verify: adm-on-path
test "$(readlink -f /usr/local/bin/adm)" = "$ADM_ROOT/bin/adm"
command -v adm

Record the resulting state

Snapshot the box into hosts/<box>/facts/, establishing the baseline that every later adm facts diffs against. From here on, anything that changes outside a runbook shows up as a reviewable diff.

runs: baseline-facts
adm facts

Next

Filename order is replay order, and a runbook may depend only on what is numbered below it. Numbering steps by 10 leaves room to insert.

runbook what it does needs
010-admin-user.md create the unprivileged account that owns this repo, and stop administering as root —
020-admin-repo-remote.md push this history somewhere that is not the box it describes 010
030-disable-cloud-init.md stop cloud-init re-provisioning a box that is already provisioned 010, 020
040-btrfs-snapshots.md snapper and snap-pac — cheap undo for routine mistakes 010
050-grub-btrfs.md GRUB entries to boot a snapshot when the box will not boot 040

030 must come after 020 for a non-obvious reason worth knowing before a rebuild: cloud-init is what installs root's authorized_keys, which 010 copies to admin. Disable it too early and a fresh box comes up with no way in. 040 must come after 010 because it grants an ACL to the admin user, and setfacl refuses an account that does not exist.

Still undocumented: what this box is actually for. Everything above is scaffolding — the account, the remote, the snapshots. No runbook yet describes the service this machine exists to run, which is the one that will matter most to whoever reads this repo next.

010 — The admin user

Required — every box runs this.

Create the unprivileged account that owns this repo and runs the runbooks.

Why not just use root

The box was adopted as root, which worked but left two problems.

Explicitness. When everything runs as root, nothing in a runbook says which commands actually need privilege — the reader cannot tell pacman -S nginx (needs root) from git log (does not). Running as admin and writing sudo in front of the privileged commands makes the privilege boundary part of the document. That is worth more here than it would be elsewhere, because this repo's whole claim is that reading it tells you what the box is.

Repo ownership. If admin owns /opt/admin but adm runs as root, git refuses to operate on the tree (detected dubious ownership) and any file a block writes lands root-owned inside an otherwise user-owned repo. One owner for the repo, and privilege acquired per-command, avoids the whole class of problem.

A useful consequence: because root may always sudo, every block written with explicit sudo works whether root or admin runs it. The runbooks are user-agnostic, which matters on a rebuild where admin does not exist yet — see the pivot note below.

Identity and privilege

admin gets no password at all (authentication is by SSH key) and passwordless sudo. Those two facts are the same decision: a password on an account whose entire purpose is unattended administration is a secret to manage with no corresponding security gain, since the SSH key already grants full access and sudo would only re-prove the same thing.

Privilege is granted by one explicit file in /etc/sudoers.d/, not by membership in wheel. Arch's convention is wheel plus an uncommented line in /etc/sudoers, but that splits the grant across two places and makes it invisible unless you think to check group membership. One named file is greppable and says exactly what it does.

The uid is pinned by $ADMIN_UID rather than left to useradd, which would take the next number above UID_MIN. On this box that reliably produced 1000 — but only because AS-FOUND.md records that the image shipped no non-root accounts, so the admin user was always the first. That is a dependency on the base image, not a decision, and it was nowhere written down as the reason.

It matters because this account owns $ADM_ROOT, its own home, the age key and the deploy key. A rebuild that numbered it differently would leave a restored archive's files owned by nobody in particular, and hosts/<box>/facts/ would report the change as drift without explaining it. The group is pinned to the same number, so uid == gid and neither depends on creation order.

ADMIN_UID is readable in the clear in <box>.env for the same reason ADMIN_USER is, and the reason is specific: this runbook runs before 015 generates the box's age key, so at this moment nothing on the machine can decrypt anything. Whatever this block needs has to be plaintext or it cannot be read at all.

runs: admin-user
: "${ADMIN_USER:?ADMIN_USER unset -- is <box>.env present?}"
: "${ADMIN_UID:?ADMIN_UID unset -- is <box>.env present?}"

# Refuse rather than renumber. Changing the uid of the account that owns
# $ADM_ROOT, the age key and the deploy key means rewriting every owner on
# disk, which is a manual job and not something a replay should start.
if id -u "$ADMIN_USER" >/dev/null 2>&1 \
   && [[ "$(id -u "$ADMIN_USER")" != "$ADMIN_UID" ]]; then
  echo "$ADMIN_USER exists with uid $(id -u "$ADMIN_USER") but <box>.env"
  echo "declares ADMIN_UID=$ADMIN_UID."
  exit 1
fi
owner="$(getent passwd "$ADMIN_UID" 2>/dev/null | cut -d: -f1 || true)"
if [[ -n "$owner" && "$owner" != "$ADMIN_USER" ]]; then
  echo "uid $ADMIN_UID already belongs to '$owner', not '$ADMIN_USER'"
  exit 1
fi

if ! id -u "$ADMIN_USER" >/dev/null 2>&1; then
  getent group "$ADMIN_USER" >/dev/null 2>&1 \
    || sudo groupadd --gid "$ADMIN_UID" "$ADMIN_USER"
  sudo useradd --uid "$ADMIN_UID" --gid "$ADMIN_USER" \
       --create-home --shell /bin/bash \
       --comment "$ADMIN_USER -- owns $ADM_ROOT, runs the runbooks" "$ADMIN_USER"
fi
# No password, ever. Key auth only; '!' is a locked hash, not an empty one.
sudo passwd -l "$ADMIN_USER" >/dev/null
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
sudo install -d -m 0755 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home"
id "$ADMIN_USER"
verify: admin-user
id "$ADMIN_USER"
test "$(id -u "$ADMIN_USER")" = "$ADMIN_UID" \
  || { echo "uid is $(id -u "$ADMIN_USER"), <box>.env declares $ADMIN_UID"; exit 1; }
test "$(id -g "$ADMIN_USER")" = "$ADMIN_UID" \
  || { echo "gid is $(id -g "$ADMIN_USER"); uid and gid are pinned equal"; exit 1; }
test "$(getent passwd "$ADMIN_USER" | cut -d: -f7)" = "/bin/bash"
# Locked password: field is '!' or starts with '!'
sudo awk -F: -v u="$ADMIN_USER" '$1==u {print $2}' /etc/shadow | grep -q '^!' \
  || { echo "$ADMIN_USER password is not locked"; exit 1; }
echo "$ADMIN_USER exists, shell ok, password locked"

Passwordless sudo

Validated with visudo -c before installation. A syntax error in /etc/sudoers.d/ makes sudo refuse to run at all, which on a box managed without console access is how you lose administrative access permanently. Never write this file in place.

runs: admin-sudo
tmp="$(mktemp)"
cat > "$tmp" <<EOF
# $ADMIN_USER owns $ADM_ROOT and runs its runbooks; see $ADM_ROOT/AGENTS.md
# Passwordless because administration is unattended and SSH key auth
# already grants equivalent access -- see runbooks/010-admin-user.md
$ADMIN_USER ALL=(ALL:ALL) NOPASSWD: ALL
EOF
sudo visudo -cqf "$tmp" || { echo "sudoers syntax check FAILED, not installing"; rm -f "$tmp"; exit 1; }
sudo install -m 0440 -o root -g root "$tmp" "/etc/sudoers.d/$ADMIN_USER"
rm -f "$tmp"
verify: admin-sudo
f="/etc/sudoers.d/$ADMIN_USER"
test "$(sudo stat -c '%a %U:%G' "$f")" = "440 root:root"
sudo visudo -cqf "$f"
sudo -n -u "$ADMIN_USER" sudo -n true \
  && echo "$ADMIN_USER has passwordless sudo" \
  || { echo "$ADMIN_USER cannot sudo without a password"; exit 1; }

Inbound SSH keys

admin inherits the same authorized keys root was adopted with, copied rather than re-listed so no key material lives in this repo. On a rebuild root's authorized_keys arrives from the image's cloud-init, so this still works.

The copy direction is deliberate: edit root's keys (or cloud-init) and replay, rather than editing admin's by hand — a replay would overwrite hand edits here.

runs: admin-ssh-keys
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
sudo install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "$home/.ssh"
sudo install -m 0600 -o "$ADMIN_USER" -g "$ADMIN_USER" \
     /root/.ssh/authorized_keys "$home/.ssh/authorized_keys"
sudo ssh-keygen -lf "$home/.ssh/authorized_keys"
verify: admin-ssh-keys
home="$(getent passwd "$ADMIN_USER" | cut -d: -f6)"
want="700 $ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%a %U:%G' "$home/.ssh")" = "$want" \
  || { echo "$home/.ssh is $(stat -c '%a %U:%G' "$home/.ssh"), expected $want"; exit 1; }
test "$(stat -c '%a %U:%G' "$home/.ssh/authorized_keys")" = "600 $ADMIN_USER:$ADMIN_USER"
n=$(sudo ssh-keygen -lf "$home/.ssh/authorized_keys" | wc -l)
test "$n" -ge 1 || { echo "no authorized keys for $ADMIN_USER"; exit 1; }
echo "$ADMIN_USER has $n authorized key(s)"

Root keeps no SSH identity of its own

Root has no ~/.ssh/config and no private key, and must never acquire either: outbound SSH from this box (the git remote, in 020) belongs to admin. Root's authorized_keys is left alone — that is inbound access granted by the image, not an identity root holds.

This verify block is an assertion about something that should stay absent, which is the only kind of guard that catches a future runbook quietly doing the convenient thing.

Every test here goes through sudo, and that is not incidental. /root is mode 0750, so an unprivileged test ! -e /root/.ssh/config succeeds because the path is unreadable, not because it is absent — the guard would report "root has no ssh config" while root happily had one. An absence assertion is only meaningful if the asserting process can actually see the thing it claims is missing. This block read as passing for exactly that wrong reason until a replay as admin exposed it.

verify: root-no-ssh-identity
sudo test -d /root/.ssh || { echo "/root/.ssh missing entirely -- unexpected"; exit 1; }
! sudo test -e /root/.ssh/config || { echo "root has an ssh config; it should not"; exit 1; }
found=$(sudo find /root/.ssh -maxdepth 1 \( -name 'id_*' -o -name '*.pem' \) -print)
test -z "$found" || { echo "root holds private key material: $found"; exit 1; }
echo "root has no ssh config and no private keys (checked with privilege)"

Hand the repo to admin

runs: admin-owns-repo
sudo chown -R "$ADMIN_USER:$ADMIN_USER" "$ADM_ROOT"
sudo git config --system --add safe.directory "$ADM_ROOT"
ls -ld "$ADM_ROOT"

safe.directory is set system-wide so that if anyone does run git here as root — during recovery, say — it works rather than failing confusingly. It is a safety net, not an invitation.

The check below covers the repo root, .git, and every tracked file. It deliberately excludes hosts/<box>/log/runs/, which is gitignored scratch output owned by whoever ran adm — when this runbook is executed by root during a rebuild, that block's own log files are necessarily root-owned, and failing on them would make the check unpassable on the one path where it matters.

verify: admin-owns-repo
cd "$ADM_ROOT"
want="$ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%U:%G' .)"    = "$want" || { echo "repo root not owned by $ADMIN_USER"; exit 1; }
test "$(stat -c '%U:%G' .git)" = "$want" || { echo ".git not owned by $ADMIN_USER"; exit 1; }
bad=$(git ls-files -z | xargs -0 stat -c '%U %n' \
      | awk -v u="$ADMIN_USER" '$1 != u { print $2 }' | head -5)
test -z "$bad" || { echo "tracked files not owned by $ADMIN_USER:"; echo "$bad"; exit 1; }
echo "$ADM_ROOT: root, .git and $(git ls-files | wc -l) tracked files owned by $ADMIN_USER"

The pivot

This runbook is the point where administration stops being root's job. Everything numbered above it was written to run either way; everything below it assumes admin.

On a rebuild that means: run 000 and 010 as root (or via any account that can sudo), then switch to admin and run the rest. Connect as ssh admin@rea-dev from here on.

Disabling root SSH login entirely is the obvious follow-up, and is deliberately not done here — it should not happen until admin logins have been confirmed working, and it deserves its own runbook so the lockout risk is considered on its own terms.

verify: pivot-note
echo "runbooks run as: $(id -un)"
sudo -n true && echo "sudo: passwordless ok"

015 — Secrets in the repo, encrypted

Required — every box runs this.

Let this repo be shared without leaking what it knows.

The point of this repo is to be a shareable declarative configuration system, which collides with the fact that configuring a real machine involves real secrets. The usual answers are both bad: keep secrets out of the repo and the repo no longer describes the box, or keep them in and the repo can never leave the building.

So: secrets live in the repo, encrypted at rest, with the key living only on the machines entitled to read them.

Why SOPS and age

option why not
age alone Encrypts whole files. An opaque blob has no reviewable history — git diff can only say "it changed", so you lose the audit trail that makes this repo worth having.
git-crypt Transparent, but also whole-file, with the same diff problem; and upstream has been quiet for years.
plain GPG Works, but key management is heavy and agent behaviour on a headless box is a recurring source of mystery failures.
SOPS + age Encrypts values, leaving keys readable, so a diff still shows which setting changed. age keys are a single line, with no keyring, no agent and no expiry. Both are in Arch's official repos.

That diff property is the deciding factor, not cryptographic strength — all four are fine on that axis.

What is and is not encrypted

.sops.yaml matches \.env$ at any depth, and sets unencrypted_regex so a few keys stay plaintext. The list is in the sops-rules block below, as unenc, and it used to be repeated here so a reader could see it without scrolling — until the two needed changing together, which is the moment a second copy stops being a convenience.

ADMIN_UID is exempt for the same bootstrap reason as ADMIN_USER, and the reason is sharper than it looks: 010 runs before this runbook generates the box's age key, so at the moment the admin account is created there is nothing on the machine that could decrypt an encrypted value. Anything 010 needs has to be readable in the clear or it cannot be read at all.

REQUIRE_CPU_LEVEL is exempt for a sharper version of the same reason. 000's cpu-capability block names it as the way to override a CPU check that is refusing to let the box start — and 000 runs before this runbook exists to decrypt anything. Encrypted, it was an escape hatch that could not be opened at the only moment anyone would reach for it.

This is not laziness. adm must read BOX_NAME and ADMIN_USER on a box where sops is not installed yet — which is every box at the start of 000, since sops is installed by 000. Encrypting them would make the repo unable to bootstrap itself. Neither is secret: one is a hostname, the other a conventional account name.

PUBLISH_S3_ENDPOINT, PUBLISH_S3_BUCKET and PUBLISH_S3_PREFIX (080) are plaintext because none of them is a secret: a regional DigitalOcean hostname, the bucket name, and the box name. Only the access key and the secret key are. Encrypting the rest was the "encrypted means secret" confusion again, and it had a concrete cost -- the export's leak gate fired on the bucket name, because heading slugs derive arch-declarative-markdown from the title Arch Declarative Markdown, so a value that was encrypted appeared in the output and not in the source. The gate was right; the classification was wrong.

FORGE_HOST_FINGERPRINT stays plaintext because a public host key fingerprint is not a secret, and is useless without knowing the host — which is encrypted.

MACHINE_USERS (045) is plaintext for the same reason and one more. Account names are not secret, and hosts/<box>/facts/users.txt already commits every one of them in the clear — see DECISIONS.md #004 — so encrypting the list here would be theatre rather than protection. The additional reason is structural: the correct value on a box with no people on it is the empty string, and SOPS does not encrypt an empty dotenv value. It writes MACHINE_USERS= and leaves it, which env-fully-encrypted then reports as an unencrypted key — correctly, having no way to tell an exempt empty value from a leaked one. That check found this the first time the key was added.

Everything else is encrypted, and adm refuses to export a value that is still ENC[...] rather than passing ciphertext into a block as if it were configuration.

No comments in the env file

SOPS encrypts dotenv comments along with values, so a commented, self-documenting env file turns into a wall of #ENC[...] the moment it is encrypted — strictly worse than no comments, because the noise looks like data. The first version of this box's <box>.env had a careful header explaining every key; all of it became ciphertext.

So the env file holds nothing but KEY=VALUE, and every explanation lives here and in README.md where it stays readable. Keys are descriptive enough to carry their own meaning, which is the only self-documentation that survives encryption.

The key

One age identity, at the location SOPS looks by default, in the admin user's home — deliberately outside the repo, so cloning the repo conveys nothing.

runs: age-key
key="$HOME/.config/sops/age/keys.txt"
install -d -m 0700 "$HOME/.config/sops" "$HOME/.config/sops/age"
if [[ ! -f "$key" ]]; then
  age-keygen -o "$key" 2>/dev/null
fi
chmod 0600 "$key"
echo "recipient: $(age-keygen -y "$key")"
verify: age-key
key="$HOME/.config/sops/age/keys.txt"
test -f "$key" || { echo "no age key at $key -- run 015"; exit 1; }
test "$(stat -c '%a %U' "$key")" = "600 $ADMIN_USER" \
  || { echo "age key has wrong mode/owner: $(stat -c '%a %U' "$key")"; exit 1; }
age-keygen -y "$key" >/dev/null || { echo "age key is unreadable"; exit 1; }
echo "age identity present, recipient $(age-keygen -y "$key")"

Back this key up somewhere other than this box

If the box dies and the repo has been shared, the encrypted values are unrecoverable without this key. Proxmox backs up /home/admin as part of the whole disk (DECISIONS.md #003), so this is not a single point of failure in practice — but a hypervisor restore is a heavy way to recover one line of text.

Put the recipient and the private key in the password manager. Nothing in this repo can check that you did, which is exactly why it is written down here.

Two tiers of key: operator and per-box

There are two kinds of identity, and the distinction is the whole security model:

identity lives can decrypt
operator the administering workstation at ~/.config/sops/age/keys.txt, plus the password manager. Never on a managed box. every box's env file, including boxes that do not exist yet
per-box the box itself, at $HOME/.config/sops/age/keys.txt only that box's own env file

The operator key is the one thing that must be backed up, and the one thing whose loss is unrecoverable. Keeping it off every managed box means a compromised or discarded VM never hands it over.

A per-box key exists so adm replay can decrypt that box's own configuration unattended. It deliberately grants nothing else: a box has no business reading another machine's secrets.

Why .sops.yaml is generated, not edited

An earlier version of this runbook kept a single rule listing the union of every recipient. That was wrong in a way worth recording, because it looked fine: SOPS consults the recipient list only at encryption time and never re-keys existing files, so the union produced chronology-dependent access — each box could read the env of every box created after it, and nothing could read the ones created before. Neither isolation nor a master key; just an artefact of the order the machines happened to be built in.

Per-path rules fix it, but path_regex cannot be templated, so every box needs its own rule — and a rule naming a box's key requires that key's public half, which that box alone knows. So each box commits its own recipient to hosts/<box>/<box>.age.pub, and .sops.yaml is derived from operator.pub plus those files.

That makes the recipient topology declarative: it is the set of committed public keys, and any box regenerates byte-identical output from them. No churn, and no box fighting another over a tracked file.

There is deliberately no catch-all rule. An .env with no matching rule makes sops refuse rather than silently encrypt to the wrong recipients — loud and recoverable, instead of quiet and not.

runs: box-recipient
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
# head -1, because a keyring can hold more than one key and age-keygen -y
# prints a recipient for each. `adm unlock` puts a restored box key there
# alongside the one this runbook generated, and without this the box's
# .age.pub was written with two recipients on two lines -- a file whose own
# comment says "Public key" and which sops-rules reads one line from.
recipient="$(age-keygen -y "$HOME/.config/sops/age/keys.txt" | head -1)"
install -m 0644 /dev/stdin "${BOX_NAME}.age.pub" <<EOF
# Public age recipient for this box, written by runbooks/015-secrets.md.
# Public key: safe to commit. The private half never leaves this machine.
$recipient
EOF
cat "${BOX_NAME}.age.pub"
verify: box-recipient
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
f="${BOX_NAME}.age.pub"
test -f "$f" || { echo "$f missing"; exit 1; }
want="$(age-keygen -y "$HOME/.config/sops/age/keys.txt")"
grep -qx "$want" "$f" \
  || { echo "$f does not contain this box's recipient"; exit 1; }
echo "$BOX_NAME recipient published: ${want:0:28}..."

Generate .sops.yaml

runs: sops-rules
cd "$ADM_ROOT"
test -s operator.pub || { echo "operator.pub missing -- see the runbook"; exit 1; }
# An operator.pub extracted from a published document carries a marker, and
# this refuses while it is there. The recipient in such a file belongs to the
# operator of the machine that document described, and an operator recipient
# is added to every box's rule -- so keeping it silently grants that person
# the ability to decrypt this box. The box cannot tell whose key it is; the
# person unpacking can, which is why this stops rather than warns.
if grep -q "ADM-OPERATOR-UNVERIFIED" operator.pub; then
  echo "operator.pub is marked UNVERIFIED and has not been checked."
  echo
  echo "It came from a published document. The recipient in it belongs to"
  echo "that document's operator, and an operator recipient can decrypt every"
  echo "box it is added to -- including this one, and including secrets that"
  echo "do not exist yet."
  echo
  echo "  if it is yours:     remove the marker comments from operator.pub"
  echo "  if it is not:       replace the recipient with your own first"
  exit 1
fi
recips() { grep -vE '^[[:space:]]*(#|$)' "$1"; }
ops="$(recips operator.pub | paste -sd,)"
test -n "$ops" || { echo "operator.pub lists no recipients"; exit 1; }
unenc='^(BOX_NAME|ADMIN_USER|ADMIN_UID|FORGE_HOST_FINGERPRINT|MACHINE_USERS|OPTIONAL_RUNBOOKS|ADOPTED_FROM|REQUIRE_CPU_LEVEL|PUBLISH_S3_ENDPOINT|PUBLISH_S3_BUCKET|PUBLISH_S3_PREFIX)$'

tmp="$(mktemp)"
{
  echo "# GENERATED by runbooks/015-secrets.md -- do not hand-edit."
  echo "# Derived from operator.pub and each hosts/<box>/<box>.age.pub, so any box"
  echo "# regenerates this file identically. One rule per box: the operator"
  echo "# recipients can read every box, a box key reads only its own."
  echo "# No catch-all: an unmatched .env makes sops refuse rather than"
  echo "# encrypt to the wrong recipients."
  echo "creation_rules:"
  for pub in $(ls -1 hosts/*/*.age.pub 2>/dev/null | sort); do
    box="$(basename "$(dirname "$pub")")"
    key="$(recips "$pub" | head -1)"
    test -n "$key" || continue
    printf '  - path_regex: ^hosts/%s/%s\\.env$\n' "$box" "$box"
    printf '    input_type: dotenv\n'
    printf "    unencrypted_regex: '%s'\n" "$unenc"
    printf '    age: %s,%s\n' "$ops" "$key"
  done
} > "$tmp"

if cmp -s "$tmp" .sops.yaml; then
  echo ".sops.yaml already current"
else
  install -m 0644 "$tmp" .sops.yaml
  echo "regenerated .sops.yaml"
fi
rm -f "$tmp"
grep -cE '^  - path_regex:' .sops.yaml | sed 's/^/rules: /'
verify: sops-rules
# A marked operator.pub must never survive into a working box: the whole
# point is that it stops the bootstrap rather than being carried past it.
grep -q "ADM-OPERATOR-UNVERIFIED" operator.pub 2>/dev/null \
  && { echo "operator.pub still carries the UNVERIFIED marker"; exit 1; }
cd "$ADM_ROOT"
grep -q "AGE_RECIPIENT_PLACEHOLDER" .sops.yaml \
  && { echo ".sops.yaml still contains a placeholder recipient"; exit 1; }

n_rules=$(grep -cE '^  - path_regex:' .sops.yaml)
n_ages=$(grep -cE '^    age:' .sops.yaml)
test "$n_rules" -ge 1 || { echo "no creation_rules in .sops.yaml"; exit 1; }
test "$n_rules" = "$n_ages" || { echo "$n_rules rules but $n_ages age lines"; exit 1; }

# Every rule must grant an operator recipient, or a box becomes unrecoverable.
while IFS= read -r op; do
  missing=$(grep -cE '^    age:' .sops.yaml)
  have=$(grep -E '^    age:' .sops.yaml | grep -cF "$op")
  test "$have" = "$missing" \
    || { echo "operator recipient ${op:0:20}... missing from $((missing-have)) of $missing rules"; exit 1; }
done < <(grep -vE '^[[:space:]]*(#|$)' operator.pub)

# This box must have a rule of its own.
grep -qE "^  - path_regex: \^hosts/${BOX_NAME}/${BOX_NAME}\\\\\.env\\\$" .sops.yaml \
  || { echo "no rule for hosts/$BOX_NAME in .sops.yaml"; exit 1; }

# And no catch-all that would quietly swallow an unknown box.
grep -qE '^  - path_regex: \\\.env\$$' .sops.yaml \
  && { echo "a catch-all rule exists; an unknown .env would be mis-encrypted"; exit 1; }

echo "$n_rules rule(s), every one granting the operator, including $BOX_NAME"

Bring existing files up to date

Adding a recipient to .sops.yaml does not re-key files that are already encrypted — SOPS consults the rules only when encrypting. An env file written before the operator key existed stays readable only by its box, which is precisely the failure this whole arrangement is meant to prevent.

sops updatekeys retrofits it, and must run somewhere that can already decrypt the file. It is a no-op once the recipients match, so this is safe on every replay.

On a rebuild this block is where the design pays off, and where it needs a hand. The rebuilt box generates a fresh identity, which is not yet a recipient of its own encrypted env file — so it cannot decrypt it, and therefore cannot re-key it either. The remedy is deliberately not to copy the operator key onto the box; it is to re-key from the operator workstation, which can read the file. The failure message below says exactly that, because this will happen mid-rebuild when nobody wants to reason it out.

runs: sync-env-recipients
cd "$ADM_ROOT"
f="$ADM_BOX_DIR/${BOX_NAME}.env"
test -f "$f" || { echo "no $f yet"; exit 0; }
if ! grep -q '^sops_version=' "$f"; then
  echo "$f is not encrypted yet; encrypt-env will handle it"
  exit 0
fi
if out="$(sops updatekeys -y "$f" 2>&1)"; then
  printf '%s\n' "$out" | tail -3
else
  printf '%s\n' "$out" | tail -4
  echo
  echo "This box cannot decrypt $f, so it cannot re-key it."
  echo "Expected on a rebuild: the old box key is gone and the new one is not"
  echo "yet a recipient."
  echo
  echo "Run this yourself, at a terminal on the box:"
  echo "    adm unlock"
  echo
  echo "It names the two private keys that can open the file and what each"
  echo "one costs, then takes whichever you have. The operator key is used"
  echo "for one command and never written to disk."
  echo
  echo "The old instruction here was to re-key from the operator workstation"
  echo "instead, which works and needs a second machine with a clone and the"
  echo "operator key -- an undocumented prerequisite in the middle of a"
  echo "restore that the published document claims to be sufficient for."
  exit 1
fi
verify: sync-env-recipients
cd "$ADM_ROOT"
f="$ADM_BOX_DIR/${BOX_NAME}.env"
# The block above exits 0 on a plaintext env, saying encrypt-env will handle
# it; this used to exit 1 on the same file, so the two disagreed about the
# same state and the verify won. On a first run the env is still plaintext
# here -- encrypt-env is four blocks further down -- so there are no
# recipients to compare and nothing this check can say.
#
# Skipping is only honest because the invariant is asserted elsewhere and not
# optionally: encrypt-env's own verify and env-fully-encrypted both fail if
# the file is still plaintext when they run. A skip with no such backstop
# would be this check quietly deciding not to do its job.
if ! grep -q '^sops_version=' "$f"; then
  echo "$f is still plaintext; encrypt-env is what encrypts it, and its own"
  echo "verify is what insists that it did. Nothing to compare yet."
  exit 0
fi
# Count age recipients baked into the file; must match the rule for this box.
in_file=$(grep -cE '^sops_age__list_[0-9]+__map_recipient=' "$f")
in_rule=$(grep -A3 -E "^  - path_regex: \^hosts/${BOX_NAME}/" .sops.yaml \
          | awk -F': ' '/^    age:/ {print $2}' | tr ',' '\n' | grep -c .)
test "$in_file" = "$in_rule" \
  || { echo "$f has $in_file recipients but its rule names $in_rule -- run sops updatekeys"; exit 1; }
echo "$f encrypted to all $in_file recipients named by its rule"

Keep the env file encrypted

This block is the safety net that makes the arrangement survive contact with real use. If someone edits <box>.env in plaintext — entirely natural, since the keys are readable and it looks like an ordinary dotenv file — the next replay re-encrypts it rather than letting the plaintext reach a shared remote.

Guarded on sops_version, the metadata SOPS appends, so an already-encrypted file is left alone rather than double-encrypted.

runs: encrypt-env
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset -- no hosts/<box>/<box>.env found}"
env_file="${BOX_NAME}.env"
test -f "$env_file" || { echo "no $env_file in $ADM_BOX_DIR"; exit 1; }
if ! grep -q "^sops_version=" "$env_file"; then
  echo "$env_file is plaintext; encrypting with sops"
  sops -e -i "$env_file"
else
  echo "$env_file already encrypted"
fi
# `grep -c` exits 1 when the count is zero, and these blocks run under
# pipefail, so on a freshly adopted box this line failed for reporting the
# number 0. Nothing was wrong with the file: adopt writes only the values adm
# must read before sops exists, and all of those are exempt by policy, so
# there was nothing to encrypt and encrypt-env said so by failing.
# [A-Z0-9_], not [A-Z_]. Without the digits this counted 5 of the 7
# encrypted values on the first box it ran on, silently omitting
# PUBLISH_S3_ACCESS_KEY and PUBLISH_S3_SECRET_KEY -- the two most sensitive
# entries in the file, left out because their names contain a 3. A count that
# under-reports exactly the values someone is auditing for is worse than no
# count. Nothing asserted it, which is why it went unnoticed: the assertion
# that every non-exempt key is encrypted belongs to env-fully-encrypted and
# is made per key, by name, so it was never fooled.
n=$(grep -cE "^[A-Z0-9_]+=ENC\[" "$env_file" || true)
echo "encrypted values: $n"
if [[ "$n" == 0 ]]; then
  echo "none yet -- every key in this file is exempt by policy. Expected on a"
  echo "box that has just been adopted and has no credentials yet; the first"
  echo "'sops set' will change it."
fi
verify: encrypt-env
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
env_file="${BOX_NAME}.env"
grep -q "^sops_version=" "$env_file" \
  || { echo "$env_file is NOT encrypted -- secrets would reach the remote in clear"; exit 1; }
# The bootstrap keys must remain readable, or a fresh box cannot start.
grep -qE "^BOX_NAME=${BOX_NAME}$" "$env_file" \
  || { echo "BOX_NAME is not readable in $env_file; bootstrap would break"; exit 1; }
grep -qE "^ADMIN_USER=" "$env_file" \
  || { echo "ADMIN_USER is not readable in $env_file"; exit 1; }
# There is deliberately no "and at least one value must be encrypted" here.
# A box that has just been adopted holds nothing but exempt keys, so such a
# check fails for a file that is exactly as the policy requires.
#
# There used to be one, reading `grep -qE "=ENC\["`, commented "or this is
# theatre". It passed on every file ever written, including an all-exempt one,
# because sops stores its own integrity hash as sops_mac=ENC[AES256_GCM,...].
# The check matched that line and never once looked at a key. Worth keeping as
# a comment: it read as the strictest assertion in the block.
#
# What it was reaching for -- no secret sitting in the clear -- is
# env-fully-encrypted's job, per key, using the same check_env the commit hook
# uses. That one cannot be satisfied by the MAC line.
echo "$env_file: encrypted, bootstrap keys still readable"
echo "encrypted values: $(grep -cE "^[A-Z0-9_]+=ENC\[" "$env_file" || true)"

Unlocking a rebuilt box: adm unlock

A rebuilt machine gets its env from the published document, encrypted, and cannot read a word of it. The file is encrypted to the box key — which went with the old machine — and to the operator key, which this runbook used to forbid copying onto a box. So the document arrived complete and unreadable, and finishing the restore meant a second machine.

adm unlock takes either key, from a person at a terminal. It refuses without one, for the reason env-fill refuses: a key on a command line is in the agent's context, its transcript, shell history and /proc. There is no flag to supply it.

The difference between the two keys is explained where it is asked, not here, because here is not where anyone will be reading it:

A key that is neither is refused by name, with nothing written. The public half is derived through age-keygen -y - on stdin, so establishing which key you pasted does not require writing it anywhere.

Adding a value: through sops, never to the file on disk

The obvious way to add a key is the wrong one, and it earns a section because the failure is loud in the wrong place and silent in the right one.

Opening hosts/<box>/<box>.env in an editor and typing NEW_KEY=value does not produce a file with one plaintext entry among the ciphertext. It produces a file sops will no longer open at all: on load it tries to decrypt every key not matched by unencrypted_regex, reaches the plaintext one, and gives up.

Error decrypting tree: Error walking tree: Could not decrypt value: Input
string ... does not match sops' data format

So the editor never appears — and the value is sitting in the working tree in the clear, with nothing complaining. That is the wrong moment to find out. The hooks below are the net at commit and push time, but exposure starts when the value is written: anything that reads the working tree in between has already had it, and the only remedy left is to revoke the secret and issue a new one.

Add values through sops, which decrypts to a temporary file, lets you edit that, and re-encrypts on save:

sops hosts/<box>/<box>.env

Or without an editor, keeping the value out of shell history and out of the process list — sops set wants a JSON-encoded value, which is what the printf is for:

read -rs TOK && printf '"%s"' "$TOK" | sops set --value-stdin \
  --input-type dotenv --output-type dotenv \
  hosts/<box>/<box>.env '["NEW_KEY"]'

unset TOK

encrypt-env above would repair such a file on the next replay, and the hooks would refuse to commit it. Neither notices it now, which is what this block is for — it is the only check in this repo that looks at the env file as it currently sits on disk.

Changing the exemption list: adm reencrypt

.sops.yaml's creation_rules apply when a file is created. An existing encrypted file carries its own copy of the policy in sops_unencrypted_regex, and that copy is the one SOPS consults — so editing .sops.yaml changes nothing about a file that already exists, and the two drift apart in silence.

That cost a real bug. ADMIN_UID was added to the list above and then set, and it came back encrypted, because sops set read the stale embedded policy; an encrypted ADMIN_UID is unreadable by 010, which runs before the age key exists. It would have failed on the next rebuild and nowhere before it. What hid it for several commits was that the only key exercising the drift until then was MACHINE_USERS, whose value was the empty string — and SOPS does not encrypt an empty value.

adm reencrypt          # no-op when the policy already matches
adm reencrypt -n       # show what would change
adm reencrypt --force  # rotate the data key regardless

It is a subcommand rather than a recipe in this prose because the manual form has three checks worth doing, and nobody does three checks by hand twice. It decrypts and re-encrypts through a pipe so no plaintext reaches disk, then refuses to replace the file unless the decrypted content is byte-identical, the recipient count is unchanged, and the new file actually records the policy it was meant to adopt. env-fully-encrypted below detects the drift and names this command as the fix, which is the pairing worth having: the check that finds a problem should say what repairs it.

Recipients are a different question and not this command's. That is sops updatekeys, driven by sync-env-recipients above.

verify: env-fully-encrypted
cd "$ADM_ROOT"
# Reuse the hooks' own check rather than writing a second one. A divergent
# copy would eventually disagree with the code that actually blocks commits,
# and the weaker of the two would be the one you were trusting.
. hooks/secret-scan.sh
env_file="${ADM_BOX_DIR:?ADM_BOX_DIR unset}/${BOX_NAME}.env"
test -f "$env_file" || { echo "no $env_file"; exit 1; }

check_env "${BOX_NAME}.env" "$(unencrypted_regex)" "$(cat "$env_file")" \
  || { echo "a value in ${BOX_NAME}.env is in the clear on disk right now"
       echo "it was almost certainly added by editing the file instead of"
       echo "going through sops -- see 'Adding a value' in this runbook."
       echo "treat the value as exposed: rotate it, do not just encrypt it."
       exit 1; }
# The file carries its own copy of the exemption policy, and that copy is the
# one sops actually uses. `.sops.yaml`'s creation_rules apply when a file is
# *created*; an existing encrypted file is governed by its embedded
# sops_unencrypted_regex. So editing .sops.yaml changes nothing about a file
# that already exists, and the two can disagree silently.
#
# That is not theoretical. Adding ADMIN_UID to .sops.yaml and then setting it
# encrypted the value, because `sops set` consulted the stale embedded regex --
# and an encrypted ADMIN_UID is unreadable by 010, which runs before the age
# key exists. It looked like a working change for as long as nobody looked.
embedded="$(grep -m1 '^sops_unencrypted_regex=' "$env_file" | cut -d= -f2-)"
declared="$(awk -F\' '/^[[:space:]]*unencrypted_regex:/ { print $2; exit }' .sops.yaml)"
test -n "$embedded" || { echo "$env_file has no embedded unencrypted_regex"; exit 1; }
if [[ "$embedded" != "$declared" ]]; then
  echo "$env_file was encrypted under a different exemption policy:"
  echo "  embedded in the file: $embedded"
  echo "  declared in .sops.yaml: $declared"
  echo "sops will use the embedded one, so a key added now may be encrypted"
  echo "against the rules. Re-encrypt the file under the current policy:"
  echo "  adm reencrypt"
  exit 1
fi

echo "${BOX_NAME}.env: every key is encrypted or deliberately exempt"
echo "exemption policy matches .sops.yaml: $declared"

Keys that are misnamed, and keys nothing reads

Two ways an env file can be wrong while looking right, both silent until now.

A key that is not a shell identifier is unusable, not merely ugly. It is accepted by the file, exported into every block, and unreachable from all of them: bash reads ${my-key:-x} as ${my} with a default, so the value is simply never seen. Tested rather than assumed — the key arrives in the environment and the block cannot name it.

A key nothing in the repo reads is almost always a typo. This is the nastier of the two, because the symptom points at the wrong thing. Write ADMIN_USR and 010 fails with ADMIN_USER unset while the env plainly contains a key that looks like it was set — so the failure reads as a bug in the runbook rather than in what was typed.

adm env-check reports both, and suggests the near match when there is one, which is what turns the second case from a puzzle into a correction.

Its notion of "read by something" is deliberately loose: the key name appearing anywhere in bin/adm, a runbook, a hook or the top-level documents is enough. A key mentioned in prose is a key somebody knows about, and the alternative — tracking which keys are used — would flag every value that is only read on a box with a feature this one does not have.

verify: env-keys-known
cd "$ADM_ROOT"
adm env-check

Git hooks: refuse to commit or push secrets in the clear

encrypt-env above repairs a plaintext env file on the next replay, which is the wrong moment — by then it may already be pushed. Two hooks move the check earlier.

pre-commit catches the mistake at the cheapest possible point, against the staged content.

pre-push closes what pre-commit structurally cannot: commits made with --no-verify, commits made before the hooks were armed, and commits that arrived from another clone. It scans the commit range actually being pushed — including blobs added and later removed inside that range, because those still land in the remote's history, where no later cleanup reaches them.

Both source a single library, hooks/secret-scan.sh, so there is one definition of what counts as a secret. Two copies of a security check drift, and the copy that drifts is always the one nobody is watching.

All three files are written by the blocks below rather than merely referenced, because a runbook that points at a file in its own repo is not reproducible from the documentation — you would need this repo to rebuild it, which is the opposite of the point. The files in hooks/ are materialised consequences, tracked so they are reviewable and active in a fresh clone, and labelled as overwritten on replay.

They are tracked rather than left in .git/hooks/, which is not version-controlled: a hook there would be missing from every clone, absent exactly when a new box is set up by someone who has not read this file. core.hooksPath must still be set locally — git deliberately offers no way for a repository to arm its own hooks, since cloning would then run the author's code.

The shared library

runs: secret-scan-lib
cd "$ADM_ROOT"
install -d -m 0755 hooks
install -m 0644 /dev/stdin hooks/secret-scan.sh <<'LIB'
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Sourced by hooks/pre-commit and hooks/pre-push. Defines checks and runs
# nothing by itself. Self-test: bash hooks/secret-scan.sh --self-test

SOPS_YAML=".sops.yaml"

# Bracketed characters so these patterns match a real key but NOT this line,
# which is what lets this file be committed at all. See the runbook.
KEY_PATTERNS='AGE-SECRET-KEY-[1]|BEGIN OPENSSH PRIVATE [K]EY|BEGIN [A-Z ]*PRIVATE KEY'

warn() { printf '%s\n' "$1" >&2; }

# Keys .sops.yaml deliberately leaves readable. Read, never duplicated, so
# the hooks cannot drift from the encryption rules.
unencrypted_regex() {
  awk -F"'" '/^[[:space:]]*unencrypted_regex:/ { print $2; exit }' \
      "$SOPS_YAML" 2>/dev/null
}

# check_env <label> <exempt-regex> <content> -> 0 clean, 1 problems
# A plaintext env holding nothing but exempt keys. `adm adopt` writes exactly
# this -- it must, because 015 has not run and nothing on the box can encrypt
# yet -- and the hooks that would object are not installed until 015 either,
# so committing adoption is both the documented next step and permanent. The
# blob then failed the scan on every publish thereafter, and the remedy was to
# allow it by sha, one box at a time, for three values the policy itself calls
# not-secret.
#
# Judged by content, not by sha: $ure *is* this repository's definition of
# what is not a secret, so a file containing only keys it names leaks nothing
# by that same definition. Conservative in two ways that matter. Any key the
# policy does not name fails, encrypted or not; and a single ENC[ value makes
# the file a sops file with its metadata missing rather than a plaintext one,
# which stays a failure -- without the MAC there is nothing to trust.
plain_env_all_exempt() {
  local ure="$1" content="$2" line key
  [[ -n "$ure" ]] || return 1
  grep -q '^sops_version=' <<<"$content" && return 1
  grep -q '=ENC\[' <<<"$content" && return 1
  while IFS= read -r line; do
    case "$line" in ''|'#'*) continue ;; esac
    [[ "$line" == *=* ]] || continue
    key="${line%%=*}"
    case "$key" in sops_*) continue ;; esac
    grep -qE "$ure" <<<"$key" || return 1
  done <<<"$content"
  return 0
}

check_env() {
  local label="$1" ure="$2" content="$3" problems=0 line key val
  if ! grep -q '^sops_version=' <<<"$content"; then
    if plain_env_all_exempt "$ure" "$content"; then
      return 0
    fi
    warn "  $label: not SOPS-encrypted (no sops_version line)"
    return 1
  fi
  while IFS= read -r line; do
    case "$line" in ''|'#'*) continue ;; esac
    [[ "$line" == *=* ]] || continue
    key="${line%%=*}"; val="${line#*=}"
    case "$key" in sops_*) continue ;; esac
    if [[ -n "$ure" ]] && grep -qE "$ure" <<<"$key"; then continue; fi
    if [[ "$val" != ENC\[* ]]; then
      warn "  $label: '$key' is not encrypted"
      problems=1
    fi
  done <<<"$content"
  return "$problems"
}

# scan_blob <label> <exempt-regex> <content> -> 0 clean, 1 problems
scan_blob() {
  local label="$1" ure="$2" content="$3" rc=0
  case "$label" in
    *.env) check_env "$label" "$ure" "$content" || rc=1 ;;
  esac
  if grep -qE "$KEY_PATTERNS" <<<"$content"; then
    warn "  $label: contains private key material"
    rc=1
  fi
  return "$rc"
}

# Two causes, two different fixes, and reaching for the wrong one is worse
# than doing nothing: encrypt-env on a hand-edited file encrypts the value
# that was already exposed and makes the problem look solved.
refuse() {
  warn ""
  warn "pre-$1: refusing to $1 secrets in the clear."
  warn ""
  warn "  a key added by hand to an encrypted file -- the usual cause:"
  warn "    delete that line, then re-add the value through sops, which"
  warn "    encrypts on save:   sops hosts/<box>/<box>.env"
  warn "    NOT encrypt-env -- it would encrypt what you already exposed."
  warn ""
  warn "  a file that was never encrypted at all, no sops_version line:"
  warn "    adm run 015-secrets.md --only encrypt-env"
  warn ""
  warn "  either way, a value that has been on disk in plaintext is exposed."
  warn "  rotate it. encrypting it afterwards hides it without un-exposing it."
  warn ""
  warn "  inspect:  sops -d --output-type dotenv hosts/<box>/<box>.env"
  warn "  override: ${REFUSE_OVERRIDE:-git $1 --no-verify}"
  warn ""
}

self_test() {
  local ure='^(BOX_NAME|ADMIN_USER)$' fails=0
  local ok='BOX_NAME=b
ADMIN_USER=a
GIT_REMOTE=ENC[AES256_GCM,data:x,type:str]
sops_version=3.13.3'
  local plain_value='BOX_NAME=b
GIT_REMOTE=ssh://git@example/r.git
sops_version=3.13.3'
  local no_meta='BOX_NAME=b
GIT_REMOTE=ENC[AES256_GCM,data:x,type:str]'
  # What adopt writes, before anything on the box can encrypt.
  local adopted='# written by adm adopt
BOX_NAME=b
ADMIN_USER=a'
  # Same shape, one key the policy does not name.
  local adopted_plus='BOX_NAME=b
ADMIN_USER=a
FORGE_TOKEN=glpat-deadbeef'
  # Only exempt keys, but one value is ciphertext and the metadata is gone:
  # a sops file that lost its MAC, not a plaintext one. The exempt-key test
  # alone would call this benign, so this is what makes the ENC[ guard in
  # plain_env_all_exempt load-bearing rather than decorative.
  local exempt_but_enc='BOX_NAME=b
ADMIN_USER=ENC[AES256_GCM,data:x,type:str]'

  scan_blob "a.env" "$ure" "$ok" 2>/dev/null \
    || { warn "self-test FAILED: encrypted env rejected"; fails=1; }
  scan_blob "a.env" "$ure" "$plain_value" 2>/dev/null \
    && { warn "self-test FAILED: plaintext secret accepted"; fails=1; }
  scan_blob "a.env" "$ure" "$no_meta" 2>/dev/null \
    && { warn "self-test FAILED: env without sops metadata accepted"; fails=1; }
  scan_blob "notes.txt" "$ure" "$(printf 'AGE-SECRET-KEY-%s' 1ABC)" 2>/dev/null \
    && { warn "self-test FAILED: private key accepted"; fails=1; }
  scan_blob "notes.txt" "$ure" "harmless text" 2>/dev/null \
    || { warn "self-test FAILED: innocent file rejected"; fails=1; }
  scan_blob "a.env" "$ure" "$adopted" 2>/dev/null \
    || { warn "self-test FAILED: plaintext env of only exempt keys rejected"; fails=1; }
  scan_blob "a.env" "$ure" "$adopted_plus" 2>/dev/null \
    && { warn "self-test FAILED: plaintext env with a non-exempt key accepted"; fails=1; }
  scan_blob "a.env" "$ure" "$exempt_but_enc" 2>/dev/null \
    && { warn "self-test FAILED: ciphertext without sops metadata accepted"; fails=1; }

  (( fails )) && { warn "secret-scan self-test FAILED"; return 1; }
  echo "secret-scan self-test ok (8 cases: encrypted, plaintext, no-metadata, key, innocent, all-exempt, all-exempt+secret, exempt-but-enc)"
}

# This file is sourced by the three hooks, and is also run directly: 015
# checks it with `bash hooks/secret-scan.sh --self-test`. That did nothing
# whatsoever until this line existed -- the file defined self_test, reached
# the end, and exited 0 -- so the check read as a test and was not one. The
# suite was covered anyway, because each hook carries the same dispatcher and
# those do run it; what was wrong was only the claim about where.
#
# Guarded on being executed rather than sourced, so sourcing this from a hook
# that was itself invoked with --self-test does not run the suite twice.
if [[ "${BASH_SOURCE[0]}" == "$0" && "${1:-}" == --self-test ]]; then
  self_test
  exit $?
fi
LIB
bash hooks/secret-scan.sh --self-test
verify: secret-scan-lib
cd "$ADM_ROOT"
test -f hooks/secret-scan.sh || { echo "hooks/secret-scan.sh missing"; exit 1; }
bash hooks/secret-scan.sh --self-test

pre-commit

runs: precommit-hook
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/pre-commit <<'PRECOMMIT'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
# Scans STAGED content, which differs from the working tree exactly when
# someone has partially staged an edit.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }

rc=0
ure="$(unencrypted_regex)"
while IFS= read -r f; do
  [[ -n "$f" ]] || continue
  content="$(git show ":$f" 2>/dev/null)" || continue
  scan_blob "$f" "$ure" "$content" || rc=1
done < <(git diff --cached --name-only --diff-filter=ACM)

(( rc )) && refuse commit
exit "$rc"
PRECOMMIT
hooks/pre-commit --self-test
verify: precommit-hook
cd "$ADM_ROOT"
test -x hooks/pre-commit || { echo "hooks/pre-commit is not executable"; exit 1; }
hooks/pre-commit --self-test

pre-push

Reads the refs being pushed from stdin, in git's documented <local-ref> <local-sha> <remote-ref> <remote-sha> format. An all-zero remote-sha means a branch the remote does not have yet, so the range becomes "everything not already on a remote"; an all-zero local-sha is a ref deletion, which pushes no content and is skipped.

Blobs are deduplicated by hash, so a file touched in thirty commits is read once.

runs: prepush-hook
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/pre-push <<'PREPUSH'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Scans every blob introduced by the commits being pushed -- including ones
# added and later removed within the range, since those still reach the
# remote's history. Closes the gap pre-commit cannot: --no-verify, commits
# predating the hooks, and commits from another clone.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }

rc=0
ure="$(unencrypted_regex)"
declare -A seen=()

while read -r _local_ref local_sha _remote_ref remote_sha; do
  [[ -n "${local_sha:-}" ]] || continue
  [[ "$local_sha" =~ ^0+$ ]] && continue          # ref deletion: no content
  # A remote sha this clone does not have means the ranges cannot be computed:
  # a new branch, or a force-push over history that was rewritten underneath
  # it. The first version let `git rev-list` fail, scanned the empty list it
  # produced, and allowed the push -- silently, and in exactly the case where
  # scanning matters most. Fall back to everything reachable from the tip.
  if [[ "${remote_sha:-}" =~ ^0+$ ]] \
     || ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
    mapfile -t commits < <(git rev-list "$local_sha")
  else
    mapfile -t commits < <(git rev-list "$remote_sha..$local_sha")
  fi
  (( ${#commits[@]} )) || { echo "pre-push: no commits resolved; refusing" >&2; exit 1; }
  for c in "${commits[@]}"; do
    [[ -n "$c" ]] || continue
    while IFS= read -r f; do
      [[ -n "$f" ]] || continue
      blob="$(git rev-parse "$c:$f" 2>/dev/null)" || continue
      [[ -n "${seen[$blob]:-}" ]] && continue
      seen[$blob]=1
      content="$(git cat-file blob "$blob" 2>/dev/null)" || continue
      scan_blob "$f" "$ure" "$content" || rc=1
    done < <(git diff-tree --no-commit-id --name-only -r "$c")
  done
done

(( rc )) && refuse push
exit "$rc"
PREPUSH
hooks/pre-push --self-test
verify: prepush-hook
cd "$ADM_ROOT"
test -x hooks/pre-push || { echo "hooks/pre-push is not executable"; exit 1; }
hooks/pre-push --self-test

scan-history

pre-push gates what leaves for the forge. A published git bundle leaves by a different door: adm publish reads the object store's credentials from the env file and uploads directly, so nothing about a push is involved and the pre-push hook never runs. Someone who commits a secret and publishes without pushing would export it, and a bundle carries all history -- including a blob added and removed again, which stays reachable from the commit that introduced it.

So the same library gets a third caller, scanning everything a bundle would carry rather than one push's worth of commits.

That difference in scope brings a problem the push hook never has to face. unencrypted_regex is the list of names exempt from encryption, and it changes as the repo learns what it needs -- MACHINE_USERS was called HUMAN_USERS for a while, and before the move to hosts/ the env file was a template at the repo root. pre-push only ever scans the commits in one push, so older history is grandfathered by the simple fact of never being looked at again. A bundle ships all of it, so every past commit gets judged by today's list, and a variable renamed years later fails retroactively. Rewriting history to satisfy a policy it predates is not a reasonable answer.

The answer is an explicit list of blobs already examined and found harmless, each with its reason. It is keyed by blob sha, which is the safe key: a sha is its content, so an approved entry cannot later be made to hold something else. Allowing a path would grant a hiding place; allowing a sha grants exactly one known string of bytes. Adding an entry means editing this runbook and saying why, which is the point -- the judgment ends up written down next to the rule it bends, not buried in a config file.

The answer is an explicit list of blobs already examined and found harmless, each with its reason. It is keyed by blob sha, which is the safe key: a sha is its content, so an approved entry cannot later be made to hold something else. Allowing a path would grant a hiding place; allowing a sha grants exactly one known string of bytes.

That list is per box, at hosts/<box>/scan-history.allow, and it is written by hand rather than by a block here. It records judgments about this repository's history, which no other machine shares — a fork starts with none and accumulates its own. It lived in this runbook for a while, with two blob shas from one repository's history inside it, which made a generic runbook carry a single box's data: exactly what AGENTS.md forbids, and nothing was checking.

Hand-written, like each box's AS-FOUND.md and for the same reason: it is a record of decisions a person made, not a state a machine can converge on. Replaying this runbook must not invent or discard one.

verify: scan-history-allow
cd "$ADM_ROOT"
# No assertion that the file exists -- a repo whose history is clean has
# nothing to allow, and that is the better state to be in. What is asserted
# is that any entry is a full 40-character sha. A short one would still match
# the grep in scan-history, but as a prefix, and a prefix allows more than the
# single blob that was actually reviewed.
bad=0; files=0
for f in hosts/*/scan-history.allow; do
  [ -e "$f" ] || continue
  files=$((files+1))
  while read -r sha _; do
    case "$sha" in ''|'#'*) continue ;; esac
    [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "$f: not a full blob sha: $sha"; bad=1; }
  done < "$f"
done
test "$bad" = 0 || exit 1
# And that no copy was left behind at the old generic path, which would be
# read by nothing and would quietly stop being maintained.
test ! -e hooks/scan-history.allow \
  || { echo "hooks/scan-history.allow still exists; it is now per-box"; exit 1; }
echo "$files per-box allowlist(s), every entry a full sha"
runs: scan-history
cd "$ADM_ROOT"
install -m 0755 /dev/stdin hooks/scan-history <<'SCANALL'
#!/usr/bin/env bash
# Managed by runbooks/015-secrets.md -- hand edits are overwritten on replay.
#
# Scans every blob reachable from every ref: the whole of what `git bundle
# --all` would carry. Not a git hook -- git has no "before you publish" event
# -- so `adm publish` calls it directly, and it fails closed.
set -uo pipefail
. "$(dirname "$0")/secret-scan.sh"
[[ "${1:-}" == --self-test ]] && { self_test; exit $?; }

# Per box, because these are judgments about one repository's history and no
# other machine shares them. Concatenated rather than chosen: a repo has one
# adopted box, and reading them all avoids needing to know which.
allowed() { cat hosts/*/scan-history.allow 2>/dev/null; }
# There is no --no-verify to suggest here; publish is not a git command.
REFUSE_OVERRIDE="review the blob, then add its sha to hooks/scan-history.allow
            via runbooks/015-secrets.md#scan-history-allow"

rc=0
ure="$(unencrypted_regex)"
declare -A used=()

# rev-list --objects prints "<sha> <path>", and the path may contain spaces,
# so the path is taken as the rest of the line rather than as a field.
while read -r blob path; do
  [[ -n "$blob" && -n "$path" ]] || continue
  content="$(git cat-file blob "$blob" 2>/dev/null)" || continue
  out="$(scan_blob "$path" "$ure" "$content" 2>&1)" && continue
  if allowed | grep -qE "^$blob([[:space:]]|\$)"; then
    used[$blob]=1
    continue
  fi
  printf '%s\n' "$out" >&2
  # The sha is the first thing anyone needs: it is how you read the blob and
  # how you would allow it. scan_blob reports a path, and a path in history
  # is ambiguous across commits.
  warn "    blob $blob -- read it with: git cat-file blob $blob"
  rc=1
done < <(
  git rev-list --objects --all \
    | git cat-file --batch-check='%(objectname) %(objecttype) %(rest)' \
    | sed -n 's/^\([0-9a-f]\{7,\}\) blob \(..*\)$/\1 \2/p' \
    | sort -u
)

# An entry that matches nothing is either a typo or a leftover from rewritten
# history. Reported, not fatal: a stale allowance grants nothing, but a list
# nobody prunes is a list nobody reads.
while read -r sha _; do
  case "$sha" in ''|'#'*) continue ;; esac
  [[ -n "${used[$sha]:-}" ]] || warn "note: allowance matches no blob in history: $sha"
done < <(allowed)

(( rc )) && refuse publish
exit "$rc"
SCANALL
hooks/scan-history --self-test
verify: scan-history
cd "$ADM_ROOT"
test -x hooks/scan-history || { echo "hooks/scan-history is not executable"; exit 1; }
hooks/scan-history --self-test
# The point of the thing: it must pass over this repo's own history. If this
# fails, something in a past commit is in the clear and no bundle may ship.
hooks/scan-history || { echo "history contains a secret in the clear"; exit 1; }

Arm them

runs: hooks-path
cd "$ADM_ROOT"
git config core.hooksPath hooks
git config --get core.hooksPath
verify: hooks-path
cd "$ADM_ROOT"
test "$(git config core.hooksPath)" = "hooks" \
  || { echo "core.hooksPath is '$(git config core.hooksPath)', expected 'hooks'"; exit 1; }
# Git tracks the executable bit; losing it would hand a clone a dead hook.
for spec in hooks/pre-commit:100755 hooks/pre-push:100755 hooks/secret-scan.sh:100644; do
  f="${spec%%:*}"; want="${spec##*:}"
  mode="$(git ls-files -s "$f" | awk '{print $1}')"
  case "$mode" in
    "$want") : ;;
    "")      echo "$f is not tracked yet -- commit it so clones get it"; exit 1 ;;
    *)       echo "$f is committed as $mode, not $want"; exit 1 ;;
  esac
done
echo "hooks armed: pre-commit, pre-push, shared library, all tracked"

The detector cannot contain its own patterns

The first version of this refused to commit itself, and correctly so: its KEY_PATTERNS spelled the age-key prefix out in full, so the hook's own source — and the runbook containing it — looked exactly like a leaked key.

The tempting fix is to exempt hooks/ and runbooks/ from the key scan. That is a loophole: a real key pasted into a runbook is a plausible mistake, and would then sail through.

The actual fix is to write each pattern so it matches a real key but not its own text, by bracketing one character:

AGE-SECRET-KEY-[1]           matches a real age key, not this line
BEGIN OPENSSH PRIVATE [K]EY
BEGIN [A-Z ]*PRIVATE KEY     already self-safe: '[' is not in [A-Z ]

The self-test builds its sample with printf for the same reason. This is a general hazard for any secret scanner kept in the repository it scans, and it is why the third pattern needed no change while the first two did.

Checking it yourself

--self-test exercises the logic against inline samples, but it does not prove git calls the hooks. These do, and leave nothing behind. From the repo root:

# pre-commit
printf 'BOX_NAME=test\nAPI_TOKEN=hunter2\n' > test.env
git add test.env && git commit -m 'should be refused'      # must fail
git reset && rm -f test.env

# pre-push: commit past the hook deliberately, then try to push
printf 'AGE-SECRET-KEY-%s\n' 1QQQ > leaked-key.txt
git add leaked-key.txt
git commit --no-verify -m 'bypassed pre-commit'
git push                                                   # must fail
git reset --hard HEAD~1 && rm -f leaked-key.txt

The second case is the one worth running: it reproduces the exact situation pre-push exists for — a secret that got past pre-commit because someone used --no-verify.

test.env deliberately sits at the repo root rather than in a box directory, so adm cannot mistake it for a real box while the test is in progress.

Limits

Prove a round trip

Configuration asserts the setup; this proves the thing that matters — that a secret written in the repo comes back out intact on this box.

verify: secrets-roundtrip
cd "${ADM_BOX_DIR:?ADM_BOX_DIR unset}"
got="$(sops -d --input-type dotenv --output-type dotenv "${BOX_NAME}.env" \
       | awk -F= '$1 == "GIT_REMOTE" { print $2 }')"
test -n "$got" || { echo "GIT_REMOTE did not decrypt"; exit 1; }
case "$got" in
  ssh://*) : ;;
  *) echo "GIT_REMOTE decrypted to something unexpected"; exit 1 ;;
esac
# And adm's own loader must agree, since that is what blocks actually see.
test -n "${GIT_REMOTE:-}" \
  || { echo "adm did not export GIT_REMOTE into this block"; exit 1; }
test "$GIT_REMOTE" = "$got" \
  || { echo "adm exported a different GIT_REMOTE than sops decrypts"; exit 1; }
echo "secret round trip ok: sops and adm agree on GIT_REMOTE"

What this does and does not protect

Does: the repo can be pushed to a shared or public remote without revealing the encrypted values. History is reviewable, because keys stay readable and only values are opaque.

Does not: protect the secrets from anyone with access to this box. The age key is on it, in plaintext, by design — adm replay has to work unattended. The deploy key in $HOME/.ssh is plaintext for the same reason. Encrypting <box>.env changes the repo's exposure, not the box's.

Does not, on its own, make this repo shareable. Encrypting one file is necessary and nowhere near sufficient — see DECISIONS.md #004 for the rest of what leaks, notably hosts/<box>/facts/, which records internal addresses, listening sockets and account names in plain text.

020 — Off-box remote for this repo

Required — every box runs this.

Give /opt/admin somewhere to live that isn't the box it documents.

Until this runbook ran, the only copy of this repo's history was on rea-dev itself, which defeats most of the purpose: the rebuild procedure was stored exclusively on the machine it exists to rebuild.

The remote is a Forgejo instance, prepared in advance. Its URL is $GIT_REMOTE from <box>.env — an encrypted value, because it names a private forge and account and this repo is meant to be shareable. So this runbook depends on 015-secrets.md having made decryption work; without it every block here fails fast with a clear message rather than operating on an empty URL.

Host and port are derived from that URL rather than stored separately, so there is one source of truth and nothing to drift.

Why a per-repo deploy key, owned by admin

The key lives at $HOME/.ssh/id_ed25519_admin_repo — named for its one job rather than called id_ed25519, because it is a deploy key scoped to a single repository, not admin's general-purpose identity. If this box ever needs to reach a second repo on the same forge, it gets its own key and its own Host alias; nothing here has to be untangled first.

Root holds no key and no SSH config, and must not acquire either — see 010-admin-user.md. Outbound SSH from this box is admin's business.

The key has no passphrase. Pushes have to work unattended, and a passphrase stored anywhere the box can read it is not a passphrase. The blast radius is bounded by what the key can reach: write access to this one repository. That is a real exposure — someone with the key can rewrite the box's own documentation — and it is accepted deliberately rather than by omission.

The comment must be a single token

A public key line is <type> <base64> <comment>, and Forgejo's deploy-key form splits it on whitespace. A descriptive, multi-word comment produces an eight-field line, which sends its parser down a path where it tries to base64-decode part of the comment and fails:

Cannot verify your SSH key: extractTypeFromBase64Key:
invalid key format: illegal base64 data at input byte 6

The first version of this block used a multi-word description naming the repo and path — informative in ssh-keygen -l output, and rejected by the only system that had to accept it. Keep the comment to one token; the runbook is where the explanation belongs.

The comment is converged, not just set at creation: if an existing key carries the wrong one, the block repairs it in place rather than leaving a key whose public line cannot be pasted anywhere. ssh-keygen -c rewrites the comment without touching the keypair, so the fingerprint is stable and the forge needs no update.

runs: admin-deploy-key
key="$HOME/.ssh/id_ed25519_admin_repo"
comment="${ADMIN_USER:-admin}@${BOX_NAME}"
install -d -m 0700 "$HOME/.ssh"
if [[ ! -f "$key" ]]; then
  ssh-keygen -t ed25519 -N "" -f "$key" -C "$comment"
  fresh=1
fi
current="$(awk '{ $1=""; $2=""; sub(/^ +/, ""); print }' "$key.pub")"
if [[ "$current" != "$comment" ]]; then
  echo "repairing key comment: '$current' -> '$comment'"
  ssh-keygen -c -P "" -C "$comment" -f "$key" >/dev/null
fi
chmod 0600 "$key"
chmod 0644 "$key.pub"
ssh-keygen -lf "$key.pub"
# Only when it was just generated. On every other run the key has long since
# been authorised and printing it again would be noise; this is the one
# moment someone has to act on it.
if [[ "${fresh:-}" == 1 ]]; then
  echo
  echo "This is a new key, so it is not authorised on the forge yet."
  echo "The forge-authorized check below prints it with the URL to add it to."
fi
verify: admin-deploy-key
key="$HOME/.ssh/id_ed25519_admin_repo"
test "$(stat -c '%a %U:%G' "$key")" = "600 $ADMIN_USER:$ADMIN_USER"
test "$(stat -c '%a' "$key.pub")" = "644"
# Passphrase-less by design: an empty passphrase must open it non-interactively.
ssh-keygen -y -P "" -f "$key" >/dev/null \
  || { echo "key is passphrase-protected; unattended push will hang"; exit 1; }
# Exactly three fields, or the forge's deploy-key form rejects the paste.
nf="$(awk '{print NF}' "$key.pub")"
test "$nf" -eq 3 \
  || { echo "public key line has $nf fields, expected 3 -- comment must be one token"; exit 1; }
ssh-keygen -lf "$key.pub"

Pin the forge's host key

Pinned as $FORGE_HOST_FINGERPRINT in <box>.env, captured from this box on 2026-10-18. That value is deliberately not encrypted: a public host key fingerprint is not a secret, and is useless without knowing the host — which is. Worth knowing before you go looking for one: this forge offers only an RSA host key, no ed25519.

This fingerprint was obtained by ssh-keyscan, which is trust-on-first-use: it proves only that something answering on that port presented this key at that moment. Because the same person administers the forge, it should be cross-checked out of band against the server's own /etc/ssh/ssh_host_rsa_key.pub — until then the pin records continuity, not authenticity.

Pinning it in the runbook rather than accepting whatever turns up means a rebuild, or a replay after a MITM, fails loudly instead of silently trusting a new key.

runs: forge-known-host
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
kh="$HOME/.ssh/known_hosts"
expected="${FORGE_HOST_FINGERPRINT:?FORGE_HOST_FINGERPRINT unset in <box>.env}"
install -d -m 0700 "$HOME/.ssh"
touch "$kh"; chmod 0600 "$kh"
if ! ssh-keygen -F "[$host]:$port" -f "$kh" >/dev/null; then
  tmp="$(mktemp)"
  ssh-keyscan -p "$port" "$host" > "$tmp" 2>/dev/null
  test -s "$tmp" || { echo "keyscan returned nothing for $host:$port"; rm -f "$tmp"; exit 1; }
  if ! ssh-keygen -lf "$tmp" | awk '{print $2}' | grep -qx "$expected"; then
    echo "HOST KEY MISMATCH -- refusing to trust $host:$port"
    echo "expected: $expected"
    echo "got:"; ssh-keygen -lf "$tmp" | sed 's/^/  /'
    rm -f "$tmp"; exit 1
  fi
  cat "$tmp" >> "$kh"; rm -f "$tmp"
fi
ssh-keygen -F "[$host]:$port" -f "$kh" -l
verify: forge-known-host
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
kh="$HOME/.ssh/known_hosts"
expected="${FORGE_HOST_FINGERPRINT:?FORGE_HOST_FINGERPRINT unset in <box>.env}"
ssh-keygen -F "[$host]:$port" -f "$kh" -l | awk '{print $3}' \
  | grep -qx "$expected" \
  || { echo "pinned host key for the forge is missing or changed"; exit 1; }
echo "forge host key pinned and unchanged"

SSH config for admin

The remote URL already carries the user and port, so this stanza's User and Port are belt and braces. Its real job is IdentityFile plus IdentitiesOnly, so git presents this one deploy key and nothing else.

Written whole from a heredoc, never appended — per AGENTS.md, an appending block duplicates its own output on the second run.

runs: admin-ssh-config
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
user="${url#ssh://}"; user="${user%%@*}"
install -d -m 0700 "$HOME/.ssh"
install -m 0600 /dev/stdin "$HOME/.ssh/config" <<EOF
# Managed by $ADM_ROOT/runbooks/020-admin-repo-remote.md
# Hand edits are overwritten on the next \`adm replay\`.

Host $host
    Port $port
    User $user
    IdentityFile ~/.ssh/id_ed25519_admin_repo
    IdentitiesOnly yes
EOF
cat "$HOME/.ssh/config"
verify: admin-ssh-config
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
test "$(stat -c '%a %U:%G' "$HOME/.ssh/config")" = "600 $ADMIN_USER:$ADMIN_USER"
g="$(ssh -G "$host" 2>/dev/null)"
grep -qx "port $port" <<<"$g" || { echo "port $port not applied"; exit 1; }
grep -q "^identityfile.*id_ed25519_admin_repo" <<<"$g" \
  || { echo "deploy key not selected for the forge"; exit 1; }
echo "ssh config resolves correctly for the forge"

The git remote

runs: git-remote
cd "$ADM_ROOT"
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
if git remote get-url origin >/dev/null 2>&1; then
  git remote set-url origin "$url"
else
  git remote add origin "$url"
fi
git remote -v
verify: git-remote
cd "$ADM_ROOT"
test "$(git remote get-url origin)" = "${GIT_REMOTE:?GIT_REMOTE unset}" \
  || { echo "origin does not match GIT_REMOTE from <box>.env"; exit 1; }
echo "origin matches GIT_REMOTE"

The manual step: authorise the deploy key

This is the one step in this repo that a human must perform by hand, and it cannot be automated away without handing the box credentials to the forge's API — a strictly larger secret than the key it would install.

Add the public key printed by admin-deploy-key as a deploy key on the forge. For Forgejo and Gitea the page is https://<host>/<org>/<repo>/settings/keys; the forge-authorized verify below prints the exact URL for this box, derived from $GIT_REMOTE, so the address is not written into a shareable file.

Enable write access. Forgejo and Gitea create deploy keys read-only by default, and a read-only key fails at git push with a message about the repository not being found rather than anything about permissions — which wastes a surprising amount of time.

The verify below fails until that is done. That is correct behaviour, not a defect: like reboot-needed in 000, it is an honest report of something untrue.

verify: forge-authorized
cd "$ADM_ROOT"
# Everything host-specific comes from <box>.env; the remote is an encrypted
# value, so 015 must have run.
url="${GIT_REMOTE:?GIT_REMOTE unset -- run 015-secrets.md so secrets decrypt}"
hp="${url#ssh://}"; hp="${hp#*@}"; hp="${hp%%/*}"
host="${hp%%:*}"; port="${hp##*:}"
[[ "$port" == "$host" ]] && port=22
path="${url#ssh://*@"$hp"/}"; path="${path%.git}"
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=10' \
  git ls-remote origin >/dev/null 2>&1 \
  || { echo "deploy key not authorised yet (or lacks access)."
       echo
       echo "Add this public key at:"
       echo "  https://$host/$path/settings/keys"
       echo "giving it WRITE access. The title can be anything; the key's"
       echo "own comment names the box, which most forges offer as the"
       echo "default."
       echo
       cat "$HOME/.ssh/id_ed25519_admin_repo.pub"
       echo
       echo "Printed in full rather than named by path, because it is a"
       echo "public key: nothing is risked by it appearing here, and the"
       echo "alternative is whoever reads this going to fetch it."
       exit 1; }
echo "forge reachable and key authorised"

The forge API token

The ssh key above pushes branches. Filing an issue, opening a pull request and merging it go over the forge's HTTP API instead, which wants a token — and adm issue, adm land and adm upstream all read it from <box>.env as FORGE_TOKEN.

No runbook mentioned it until now, because bin/adm reads it rather than a block does. That had a consequence beyond the documentation gap: adm adopt and adm env-fill both derive the list of keys a new box needs from the ${KEY:?} assertions in these runbooks, so neither knew the most secret value in the repository existed. A box bootstrapped from scratch got as far as 015 with nothing telling it to collect a token, and found out at the first adm land.

Create one in the forge under Settings → Applications → Generate New Token with the write:issue scope, then add it with adm env-fill — never by editing the file, for the reasons 015 gives.

verify: forge-token
cd "$ADM_ROOT"
: "${FORGE_TOKEN:?not set in $BOX_NAME.env -- create one in the forge under Settings -> Applications (scope write:issue) and add it with: adm env-fill}"
# Checked through adm rather than curl. adm derives the API base and the
# owner/repo slug from GIT_REMOTE, and doing that again here would be a
# second derivation free to disagree with the one that does the work. A dry
# run exercises exactly that path, and prints no token -- which a curl would,
# because the Authorization header would sit in argv where every process on
# the box can read it. 070 makes the same argument about agent arguments.
#
# -b '' rather than no body: with no -b and a non-tty stdin, adm issue reads
# the body from stdin, and a verify block does not have one to give.
adm issue -n -b '' "probe: can adm address the forge API" >/dev/null
echo "FORGE_TOKEN present; adm can address the forge API"

Why this step is not automated

The forge has an API for deploy keys, so 020 could register it and the manual step would disappear. It does not, deliberately.

Adding a key through POST /repos/{owner}/{repo}/keys needs a token with repository scope. FORGE_TOKEN is created with write:issue, which is what adm issue, adm land and adm upstream need, and it is not enough — measured, not assumed:

GET /repos/<slug>/keys  ->  HTTP 403 Forbidden

The finest scope that can add a key also permits editing the repository's settings, cutting releases and deleting branches. So automating one manual step would mean every box storing a credential that can reshape its repository — and since 080 embeds the encrypted env, that wider token travels inside the published document. A larger prize behind the same ciphertext, to save one paste.

What is automated instead is the part that costs nothing: the key is printed in full, beside the URL to add it to. An agent can do that much safely, because a public key is public — which is the one thing in this repository an agent may hold and read aloud.

What a rebuild means for this key

A rebuilt box generates a new keypair, so the forge step must be repeated by hand. That is an honest gap in the "replay reproduces the box" claim — one manual action sits in the middle of it.

The alternative is to treat the private key as a managed secret: keep it in the password manager and have this runbook install it rather than generate it, making replay fully unattended at the cost of a long-lived secret stored off-box. Not chosen yet; generating fresh is the safer default and the manual step is cheap at this scale. Revisit if this box is ever rebuilt often enough for it to chafe.

Push

Safe to re-run: an up-to-date branch pushes as a no-op. It fails on genuine divergence, which is a real condition worth stopping for rather than papering over with a force push.

On a rebuild you would clone from this remote rather than push to it — the repo has to exist on the box before it can replay anything, so cloning is the bootstrap. See the rebuild section in README.md.

runs: initial-push
cd "$ADM_ROOT"
git push -u origin master
git branch -vv --list master
verify: initial-push
cd "$ADM_ROOT"
# master against origin/master by name. Using HEAD and @{upstream} meant this
# reported "master and its upstream disagree" while standing on a branch that
# simply had no upstream yet -- which says nothing about master.
test "$(git rev-parse master)" = "$(git rev-parse origin/master)" \
  || { echo "master and origin/master disagree"; exit 1; }
echo "master is in sync with origin/master"

Upstream: where the runbooks come from

origin is this machine's private repo. upstream is the public adm repository every box's generic half comes from, and this repo is a fork of it: its first commit is upstream's first commit, same object id. That is what makes pulling fixes an ordinary merge instead of an argument.

That first commit's id is written down in the check below, the way FORGE_HOST_FINGERPRINT is written down in 015: a fact about something remote, recorded so it can be checked without asking the remote. The check needs it because a box rebuilt from the published document has no history and begins with git init -- a fresh, unrelated root. Asking upstream would answer the question, but only from a box that can reach the network, and the boxes most likely to have the wrong root are exactly the ones rebuilt offline. Recorded, the answer is available always, so a wrong root is a failure rather than a skipped check.

runs: upstream-remote
cd "$ADM_ROOT"
test -n "${ADM_UPSTREAM:-}" || ADM_UPSTREAM=https://github.com/EnigmaCurry/adm.git
git remote get-url upstream >/dev/null 2>&1 \
  || git remote add upstream "$ADM_UPSTREAM"
git remote set-url upstream "$ADM_UPSTREAM"
# Fetch over https, push over ssh. Pulling from upstream is routine and must
# not depend on an agent being forwarded; pushing is occasional, needs a key
# the public repo has authorised, and is the direction worth making awkward.
test -n "${ADM_UPSTREAM_PUSH:-}" \
  || ADM_UPSTREAM_PUSH="$(printf '%s' "$ADM_UPSTREAM" \
       | sed -E 's|^https://([^/]+)/|git@\1:|')"
git remote set-url --push upstream "$ADM_UPSTREAM_PUSH"
git remote get-url upstream
git remote get-url --push upstream
verify: upstream-remote
cd "$ADM_ROOT"
url="$(git remote get-url upstream 2>/dev/null)" \
  || { echo "no upstream remote; run this runbook"; exit 1; }
case "$url" in
  https://*) : ;;
  *) echo "upstream should be fetched over https: it is public, needs no key,"
     echo "and an ssh url would make a routine fetch depend on an agent"; exit 1 ;;
esac
# A fork shares upstream's root commit. Without that there is no merge base,
# and every per-box file arrives as a modify/delete conflict on first merge.
#
# Compared against the recorded id, not against a fetch. This check used to
# fetch and skip itself when the network was down, which passed the one box
# that most needed failing: a box rebuilt from the published document has an
# unrelated root by construction, and an offline rebuild is how it gets there.
# Declared here and nowhere else. Override it together with $ADM_UPSTREAM
# when forking adm for a fleet of your own: a root commit from one upstream
# means nothing to another.
test -n "${ADM_UPSTREAM_ROOT:-}" \
  || ADM_UPSTREAM_ROOT=5f06bf3ecc68dca60df75b27a247521ed6e38ce4
root_local="$(git rev-list --max-parents=0 HEAD | tail -1)"
if test "$root_local" != "$ADM_UPSTREAM_ROOT"; then
  echo "this repo does not share upstream's root commit:"
  echo "  here:     $root_local"
  echo "  upstream: $ADM_UPSTREAM_ROOT"
  echo "merges would need --allow-unrelated-histories and would conflict"
  echo "on every per-box file. See DECISIONS 007."
  echo
  echo "A repo rebuilt from the published document starts this way, because"
  echo "git init makes its own root. To re-fork it without losing the work:"
  echo
  echo "  git fetch upstream master"
  echo "  git reset --soft upstream/master     # move HEAD, keep the tree"
  echo "  git commit -m 'adopt this box onto upstream'"
  echo
  echo "The soft reset leaves the index holding this tree while HEAD becomes"
  echo "upstream's tip, so the commit that follows has upstream as its parent"
  echo "and every file exactly as it is now. Not --amend: that would replace"
  echo "upstream's own tip commit instead of building on it."
  echo
  echo "Files upstream tracks that the document does not carry -- hooks/ --"
  echo "will show as deleted until 015 writes them again, which it does."
  echo "Then push once with --force-with-lease; the root commit changed."
  exit 1
fi
# When the network is there, confirm the recorded id is still upstream's. It
# would not be if upstream were rewritten -- which has happened once -- and a
# recorded fact that has gone stale is worse than no fact.
if git fetch -q upstream 2>/dev/null; then
  root_up="$(git rev-list --max-parents=0 upstream/master | tail -1)"
  test "$root_up" = "$ADM_UPSTREAM_ROOT" \
    || { echo "the recorded root is stale: this runbook says"
         echo "  $ADM_UPSTREAM_ROOT"
         echo "but $(git remote get-url upstream) now roots at"
         echo "  $root_up"
         echo "upstream was rewritten. Update ADM_UPSTREAM_ROOT in this"
         echo "runbook, and expect to re-fork every box."; exit 1; }
fi
push="$(git remote get-url --push upstream 2>/dev/null)"
case "$push" in
  git@*|ssh://*) : ;;
  *) echo "upstream pushes over '$push'; it should be ssh, so a push uses a"
     echo "key rather than prompting for a password on a headless box"; exit 1 ;;
esac
echo "upstream $url (push $push), sharing root ${root_local:0:12}"

Taking a fix from upstream

git fetch upstream
git log --oneline master..upstream/master   # what is new up there
git merge upstream/master
adm verify                                  # the acceptance test

The last line is the point. Merging changes the runbooks; adm verify says whether this machine still matches them, and each failure names the adm run now owed. A merge that leaves verify green changed nothing about this box.

Conflicts appear only where this machine edited a generic file, which is a signal rather than a nuisance: it means a fix was made here that belongs upstream.

Sending a fix upstream

adm upstream -n        # what would be published
adm upstream           # branch it, push it, print the pull request link
adm upstream --pull    # after it is merged: fetch, check, merge, push

Cherry-picking commits was the obvious design and does not work here. adm run rewrites the box journal on every invocation, so it lands in whatever commit follows: of the 117 commits in this repo's history before the split, 78 touched both halves, every one of them because of that journal. A workflow that can only carry generic-only commits would carry an eighth of the work.

So adm upstream publishes the diff restricted to generic paths, which does not care how local commits are arranged. It takes the delta between upstream/master and master with hosts/ and operator.pub excluded, applies it to a branch cut from upstream/master, and pushes that. The exclusion is a git pathspec rather than a filter afterwards — a filter applied afterwards is what published one box's facts the first time this was attempted, see DECISIONS 007.

It refuses to run with uncommitted changes to generic files, because the delta comes from committed state and a publish that silently omits what you just wrote is worse than no publish. It builds the branch in a temporary worktree, so the live checkout never has its files swapped for another branch's while the machine depends on them. And it never pushes master: the branch is the deliverable, a pull request is the merge, and upstream enforces that too.

What the pull request says

Publishing a diff loses something a cherry-pick would have kept: the commit messages, which on this side are the documentation. Upstream recorded only sync the generic half from a fork at 439b139 and a merge commit naming the same sha — a thread leading back into a private repository, useful to one person.

A forge fills the pull request form from the single commit on a branch, and the sync branch has exactly one commit, so a real message there is the whole fix: title and body arrive filled in, with nothing opening the request but a person. So adm upstream builds that message from the fork commits whose changes it is publishing. One commit contributes its message verbatim — it was written for this, and copying beats summarising. Several get a subject naming the places touched and a body quoting each in turn.

Which commits those are depends on knowing where the last sync stopped, and the only record both sides can see is upstream's own history, so the sha is parsed back out of it. That makes the trailer a format rather than a sentence, and adm upstream checks that it can read back the sha it just wrote: a message that cannot be parsed would break the sync after it, which is a failure that surfaces a week later as a commit list reaching back to the first commit in the repo. That is not hypothetical — it is what the first run of this did, because every sync already upstream was written in the older wording and the new parser did not know it.

adm upstream -n prints the message it would use, so the description can be read before anything is pushed. -m still overrides the prose entirely — but not the trailer, which is appended to a hand-written message that does not already carry this fork's sha. That is machine data rather than prose, and requiring an operator to remember a format is how it goes missing: the one sync published with -m carried no trailer, and the sync after it had no boundary and re-listed two changes already upstream.

Which puts a requirement on the fork's commit messages that they did not have before: each one is a public pull request description, read by someone with no issue to click through to and no access to the repository it came from. A message written against the context of an issue reads as a defence of a fix for an unstated problem. State the problem first and the implementation second — 070 says this to the agent as well, since it is the one writing most of them.

verify: sync-message-boundary
cd "$ADM_ROOT"
# The parse must ignore the format when it appears inside quoted prose. That
# is not a hypothetical: _sync_message quotes fork commit messages verbatim,
# and the commit that introduced it quotes an older sync line in its own
# explanation. A substring search found that sha instead of the trailer --
# caught in _sync_message, which refuses, but the same bug in _last_sync_sha
# chooses a boundary from a previous era and publishes a commit list reaching
# past it, with nothing to indicate anything went wrong.
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)

quoted = """sync 2 generic changes: 020

* 020: say what the pull request says

  Upstream recorded only "sync the generic half from a fork at 439b139"
  and a merge commit naming the same sha.

Synced from the fork's generic half at abc1234."""
got = mod._parse_sync_sha(quoted)
assert got == "abc1234", "quoted sha won over the trailer: %r" % got

# Every sync already upstream is written this way; it has to stay readable.
assert mod._parse_sync_sha(
    "sync the generic half from a fork at 439b139") == "439b139"
assert mod._parse_sync_sha("no sync here at all") == ""

# A message written by hand must end up carrying the trailer too. -m replaces
# the generated message entirely, and the check for this used to live inside
# the generator, so it only ever examined messages that could not fail. The
# commit-identity sync went out with -m, carried no trailer, and the sync
# after it re-listed two changes that were already upstream.
got = mod._with_sync_trailer("a subject and nothing else", "abc1234")
assert mod._parse_sync_sha(got) == "abc1234", got
# A trailer copied from an older message must not win: ours is appended after
# it and the parse takes the last match.
stale = "subject\n\nSynced from the fork's generic half at 0000000."
got = mod._with_sync_trailer(stale, "abc1234")
assert mod._parse_sync_sha(got) == "abc1234", got
# And one that already says the right thing is left alone.
assert mod._with_sync_trailer(got, "abc1234") == got

print("boundary: trailer beats quoted prose, old format read, -m always carries one")
PY

Bringing it back

After the pull request is merged, pull it back so the delta returns to zero:

adm upstream --pull

This used to be documented as git fetch upstream && git merge upstream/master, which is two of the four things it needs to be and leaves adm verify red. initial-push compares master to origin/master by name, and a merge that has not been pushed is a disagreement — so following the runbook exactly produced a failure about the documentation rather than about the box, which is the worst kind to hand someone.

The push is the small half. The half that matters is the check in the middle, and it is the reason this is a command rather than a longer instruction.

A merge faithfully replays a deletion. .sops.yaml reached the public repo once, carrying one box's rule and recipients. It was deleted there to fix that, and pulling the fix back deleted this box's own .sops.yaml and broke every sops write on it. Nothing was wrong with the merge; it did exactly what it was told.

So --pull refuses when upstream deletes a path that adm upstream never publishes. The rule is derived from that exclusion list rather than written down again, because the two questions are one question: a file upstream never received is a file upstream has no business deleting. A deletion of anything generic is upstream's to make, and passes with a note.

It compares against the merge base, not against the tracking ref as it stood before its own fetch. That was the first implementation, and it only worked when nothing had fetched already — a condition no operator maintains. The merge base is where the two histories last agreed, so the range is what upstream has done since, no matter who fetched when. The test that caught this did so only because a second check, run after the merge, refused to push a tree that had lost something; that check is still there, and now it should never be the one that fires.

It also refuses to run off master, or with a dirty tree, because a merge that stops halfway leaves neither state.

verify: pull-back-protects-owned
cd "$ADM_ROOT"
# The classification, not the plumbing: given a path, is it one upstream
# never receives? Everything else about --pull is git doing what git does.
PYTHONDONTWRITEBYTECODE=1 python3 - <<'PY'
import importlib.machinery as M, importlib.util as U, sys
sys.argv = ["adm"]
loader = M.SourceFileLoader("a", "bin/adm")
mod = U.module_from_spec(U.spec_from_loader("a", loader))
loader.exec_module(mod)

# The file whose deletion broke the box, and the shapes around it.
# A box name that is nobody's. Writing this box's name here put it in a
# generic runbook, which 080's check refused -- correctly, and immediately.
owned = [".sops.yaml", "operator.pub",
         "hosts/.gitignore", "hosts/somebox/somebox.env"]
shared = ["runbooks/015-secrets.md", "bin/adm", "README.md",
          "skills/adm.md", "hooks/secret-scan.sh",
          # Named like an owned path but not one. A rule written as a
          # substring test rather than a prefix would call these private.
          "docs/hosts-and-boxes.md", "notes/.sops.yaml.md"]
for p in owned:
    assert mod._never_published(p), "%s should be ours alone" % p
for p in shared:
    assert not mod._never_published(p), "%s is generic" % p

# And that the rule is the exclusion list, not a copy of it: every exclusion
# must classify as owned, or the two have drifted apart.
for spec in mod.UPSTREAM_EXCLUDE:
    pat = spec[len(":(exclude)"):]
    sample = pat + "x/y" if pat.endswith("/") else pat
    assert mod._never_published(sample), "%s not covered" % sample
print("pull-back guards %d excluded path(s), and nothing generic"
      % len(mod.UPSTREAM_EXCLUDE))
PY

Without pulling back, the next adm upstream would offer the same changes again.

030 — Disable cloud-init

Required — every box runs this.

Stop cloud-init from running at boot, now that the box is provisioned and long-lived.

Why

cloud-init exists to turn a generic image into a specific machine on first boot. That job is finished and will not be done again — this box is managed by the runbooks in this repo. Leaving it enabled has two costs.

It re-runs modules on every boot and reverts state it considers its own. The live example is the hostname. /etc/cloud/cloud.cfg has preserve_hostname: false with both set_hostname and update_hostname in the module list, and /var/lib/cloud/data/previous-hostname records arch — cloud-init set that name and tracks it. Renaming the box without disabling cloud-init invites it to be renamed back at the next reboot.

Worse, the seed's meta-data contains only an instance-id and no local-hostname, so what cloud-init would actually do on a renamed box is ambiguous rather than merely unwanted. Disabling it replaces a question with a fact.

It is five boot-time services and a systemd generator parsing an attached seed device, for no remaining benefit.

Why this runbook is numbered after 010 and 020

On a rebuild, cloud-init must be allowed to do its job first: it is what installs root's authorized_keys, which 010 copies to admin, and what writes the persistent network configuration this box boots with. Disable it before that and a fresh box comes up with no way in.

So the order is deliberate — provision, adopt, then disable.

One ordering wart, stated plainly: the hostname is set in 000, which replays before this runbook. On a fresh box that means a reboot occurring between the two would let cloud-init revert the name. Both blocks are idempotent and 000's hostname verify catches the reversion, so a replay repairs it — but if you are rebuilding, run this runbook before rebooting.

Preconditions: what would make disabling unsafe

cloud-init renders network configuration from the seed. If it wrote that config somewhere ephemeral, disabling it would strand the box at next boot with no network — and this box is administered as though no hypervisor console exists, so that is unrecoverable.

Checked here rather than assumed. On this box it writes /etc/systemd/network/10-cloud-init-eth0.network, which is persistent, and /run/systemd/network/ is empty. Keep this as a standing assertion: it is cheap, and it is the difference between a safe change and a stranded host.

verify: network-config-persistent
shopt -s nullglob
persistent=(/etc/systemd/network/*.network /etc/systemd/network/*.netdev)
if (( ${#persistent[@]} == 0 )); then
  echo "FAIL: no persistent network config in /etc/systemd/network/"
  echo "Disabling cloud-init could leave this box with no network at boot."
  sudo ls -la /run/systemd/network/ 2>/dev/null || true
  exit 1
fi
printf 'persistent network config: %s\n' "${persistent[@]}"
networkctl --no-pager status 2>/dev/null | grep -E "State:|Address:|Gateway:" || true

Disable it

Two mechanisms, because they cover different things.

The flag file /etc/cloud/cloud-init.disabled is the supported switch. cloud-init is not enabled by ordinary unit symlinks here — cloud-init status reports boot_status_code: enabled-by-generator, meaning /usr/lib/systemd/system-generators/cloud-init-generator decides at every boot whether to pull the units in. That generator checks for this file, so masking units alone would leave the generator still making the decision.

Masking the units then makes the intent visible to anyone reading systemctl list-unit-files, and blocks a manual or dependency-driven start. Belt and braces, in the order that matters: flag first, masks second.

runs: cloud-init-disable
# Refuse to proceed if the precondition above does not hold.
shopt -s nullglob
persistent=(/etc/systemd/network/*.network /etc/systemd/network/*.netdev)
(( ${#persistent[@]} > 0 )) || {
  echo "refusing: no persistent network config in /etc/systemd/network/"; exit 1; }

sudo install -d -m 0755 /etc/cloud
sudo install -m 0644 /dev/stdin /etc/cloud/cloud-init.disabled <<'EOF'
Disabled deliberately. See /opt/admin/runbooks/030-disable-cloud-init.md

cloud-init's provisioning job is complete and this box is managed by the
runbooks in /opt/admin. Removing this file re-enables cloud-init at the next
boot, which may revert the hostname and rewrite network configuration from
the seed device.
EOF

sudo systemctl mask --quiet \
  cloud-init-main.service \
  cloud-init-local.service \
  cloud-init-network.service \
  cloud-config.service \
  cloud-final.service \
  cloud-init.target

sudo cloud-init status --long 2>&1 | head -4 || true
verify: cloud-init-disable
sudo test -f /etc/cloud/cloud-init.disabled \
  || { echo "the cloud-init.disabled flag is missing"; exit 1; }

for u in cloud-init-main cloud-init-local cloud-init-network \
         cloud-config cloud-final; do
  state="$(systemctl is-enabled "$u.service" 2>&1 || true)"
  test "$state" = "masked" || { echo "$u.service is '$state', expected masked"; exit 1; }
done
test "$(systemctl is-enabled cloud-init.target 2>&1 || true)" = "masked" \
  || { echo "cloud-init.target is not masked"; exit 1; }

# The authority on whether it will run again.
status="$(sudo cloud-init status 2>&1 || true)"
grep -qi "disabled" <<<"$status" \
  || { echo "cloud-init still reports: $status"; exit 1; }

echo "cloud-init disabled by flag, 6 units masked, status: $status"

What was deliberately not done

The package is still installed. Disabling is reversible by deleting one file; pacman -Rns cloud-init is the stronger version and remains available if you want the surface gone entirely. Keeping it costs a few megabytes and nothing at boot, and keeps a rebuild's first boot working without special handling.

/var/lib/cloud is left in place. It is the record of what was provisioned — datasource, instance id, the hostname cloud-init set — and AS-FOUND.md cites it. Deleting it would destroy evidence to reclaim nothing.

The cidata seed device is still attached. /dev/sr0 is a 4 MiB iso9660 image labelled cidata holding meta-data, network-config, user-data and vendor-data. Detaching it is a hypervisor-side action and therefore outside what any runbook here can do. Harmless now that nothing reads it.

Confirming it stuck

The honest test is a reboot: set the hostname, reboot, and check the name survived. Until that has happened, this runbook has demonstrated that cloud-init reports itself disabled, not that it stays out of the way.

verify: hostname-survives-boot
# Cross-check: the running hostname matches <box>.env, and cloud-init is not
# positioned to change it. Meaningful after a reboot; cheap before one.
test "$(hostnamectl --static)" = "$BOX_NAME" \
  || { echo "static hostname '$(hostnamectl --static)' != BOX_NAME '$BOX_NAME'"; exit 1; }
sudo test -f /etc/cloud/cloud-init.disabled \
  || { echo "cloud-init re-enabled; the hostname is no longer safe"; exit 1; }
echo "hostname $BOX_NAME, cloud-init disabled; boot-safe"

040 — btrfs snapshots

Required — every box runs this.

Make routine mistakes cheap to undo: a snapshot before every package transaction, a timeline of scheduled snapshots, and file-level restore from any of them.

This is the capability the box was already capable of and wasn't using. The root filesystem is btrfs with compress-force=zstd:3, so snapshots cost almost nothing — they are copy-on-write references, not copies.

What this gives you, precisely

What this does NOT give you — read this before relying on it

These are not backups, and are not meant to be. Every snapshot lives on the same partition, in the same btrfs filesystem, on the same virtual disk. A failed disk or corrupted btrfs metadata takes the snapshots with it. They protect against you, not against hardware.

That is a deliberate division of labour, not a gap. Backups are taken at the hypervisor layer, covering the whole VM: the entire disk including the ESP and the partition table, stored off-box. The two tiers are complementary and cover different failures:

tier covers does not cover
hypervisor whole disk, ESP, partition table, host loss, off-box fine-grained or instant recovery; nothing visible from inside the guest
btrfs snapshots (here) instant file-level undo, pre-pacman state, in-guest disk failure, guest loss, anything outside the root filesystem

So no off-box btrfs send is configured, and none is wanted — replicating snapshots off-box would duplicate the hypervisor's job less well. btrbk remains the tool if that ever changes.

Whole-system rollback is not set up, and cannot be cleanly, in this layout. Root is mounted from the btrfs top-level subvolume (FS_TREE, subvolid=5) rather than from an @ subvolume:

/dev/sda3 btrfs rw,...,subvolid=5,subvol=/
Name: <FS_TREE>    default subvolume: ID 5 (FS_TREE)

The usual snapper rollback works by making a snapshot the default subvolume so the next boot lands in it. That idiom assumes root is an ordinary subvolume that can be swapped out. When root is the top level, there is nothing to swap — the snapshots live inside the very subvolume you would be replacing.

So file-level restore works fine, and "roll the entire system back to 09:00" does not. Fixing it means moving root into an @ subvolume — a separate and considerably larger change.

It is probably not worth doing. Whole-VM rollback already exists at the hypervisor layer, and it is strictly more thorough than the btrfs version: it reverts the disk, not just one filesystem. Restructuring the subvolume layout to obtain a weaker version of a capability we already have would be effort spent for a worse result. Recorded as a known limitation, deliberately not a roadmap item.

/efi is not covered. It is a separate vfat partition on an automount. /boot is covered, because it lives inside the root filesystem — so a snapshot holds a matching kernel, initramfs and GRUB config, which is what you want when inspecting an older system state.

The swap subvolume is excluded automatically. btrfs snapshots are not recursive, and /swap is its own subvolume, so the active 512 M swapfile is never captured. This is necessary, not incidental: btrfs cannot snapshot a subvolume containing an active swapfile.

Why this is numbered after 010

Snapshots are the kind of thing you want early — ideally before making any other change, so there is something to roll back to. It cannot go earlier, because the retention policy below sets ALLOW_USERS="$ADMIN_USER" with SYNC_ACL=yes, and snapper applies that as a POSIX ACL. setfacl refuses an unknown user outright:

setfacl: Option -m: Invalid argument near character 3

So this runbook depends on 010-admin-user.md having created the account, and filename order is replay order — a runbook may depend only on what is numbered below it. It was originally numbered 005, which passed here purely because admin already existed, and would have failed on a genuine fresh-box replay.

Preconditions: root must be btrfs, and the admin user must exist

Everything here is btrfs-specific, so assert it rather than assume it. On a box whose root is ext4 this runbook must refuse loudly instead of half-working — see the conversion discussion in DECISIONS.md #002.

verify: root-is-btrfs
fstype="$(findmnt -no FSTYPE /)"
test "$fstype" = "btrfs" || {
  echo "root filesystem is '$fstype', not btrfs -- this runbook does not apply"
  echo "see DECISIONS.md #002 for the ext4 -> btrfs conversion options"
  exit 1; }
id -u "${ADMIN_USER:?ADMIN_USER unset -- is <box>.env present?}" >/dev/null 2>&1 || {
  echo "user '$ADMIN_USER' does not exist -- run 010-admin-user.md first"
  echo "SYNC_ACL cannot grant ACLs to an unknown user"
  exit 1; }
echo "root is btrfs ($(findmnt -no SOURCE /)), compression: $(findmnt -no OPTIONS / | tr ',' '\n' | grep -E '^compress' || echo none)"

Packages

package why
snapper snapshot management, retention policy, systemd timers
snap-pac pacman hooks that snapshot before and after each transaction
runs: snapshot-packages
sudo pacman -S --needed --noconfirm snapper snap-pac
verify: snapshot-packages
pacman -Qi snapper  >/dev/null || { echo "snapper missing"; exit 1; }
pacman -Qi snap-pac >/dev/null || { echo "snap-pac missing"; exit 1; }
echo "snapper $(pacman -Q snapper | awk '{print $2}'), snap-pac $(pacman -Q snap-pac | awk '{print $2}')"

The root snapper config

snapper create-config fails if the config already exists, so it is guarded on the config file — the authoritative artefact — rather than on parsing list-configs table output.

Creating the config also creates /.snapshots as a subvolume. That matters: because it is a subvolume and snapshots are not recursive, snapshots never contain previous snapshots.

runs: snapper-config
if [[ ! -f /etc/snapper/configs/root ]]; then
  sudo snapper -c root create-config /
fi
sudo snapper -c root list-configs
verify: snapper-config
sudo test -f /etc/snapper/configs/root || { echo "no snapper config named 'root'"; exit 1; }
sudo btrfs subvolume show /.snapshots >/dev/null 2>&1 \
  || { echo "/.snapshots is not a btrfs subvolume"; exit 1; }
echo "snapper config 'root' present; /.snapshots is a subvolume"

Retention policy

Left at defaults, snapper accumulates snapshots indefinitely in the number category — and with snap-pac creating a pair per pacman transaction, that grows faster than you would guess on a rolling release.

Chosen deliberately, with 250 GiB free and 1.5 GiB in use:

Because the hypervisor owns backup, these snapshots only need to cover the convenience horizon — "undo what I did recently" — not retention of record. Anything older than about a week is the hypervisor's job, and keeping a six-month btrfs timeline would imply a durability guarantee this tier does not actually provide.

setting value why
TIMELINE_LIMIT_HOURLY 12 half a day of fine granularity — the common case
TIMELINE_LIMIT_DAILY 7 a week, which is the useful convenience horizon
TIMELINE_LIMIT_WEEKLY 2 a little slack past the week
TIMELINE_LIMIT_MONTHLY 0 month-old recovery is the hypervisor's tier, not this one
TIMELINE_LIMIT_YEARLY 0 likewise, and a year-old snapshot of a rolling-release box is useless
NUMBER_LIMIT 50 snap-pac pairs; ~25 transactions of history
NUMBER_LIMIT_IMPORTANT 20 pairs marked important (kernel, systemd, …) are kept longer

ALLOW_USERS=admin plus SYNC_ACL=yes lets admin list, diff and read snapshots without sudo. This grants no new power — admin already has passwordless sudo — it just makes browsing for a file to restore pleasant enough that it actually gets used.

btrfs quotas are deliberately left off. Snapper can use qgroups for space-aware cleanup, but qgroups carry a real performance cost on btrfs and a long history of sharp edges. Count-based retention is sufficient here.

runs: snapper-retention
sudo snapper -c root set-config \
  TIMELINE_CREATE=yes \
  TIMELINE_CLEANUP=yes \
  TIMELINE_LIMIT_HOURLY=12 \
  TIMELINE_LIMIT_DAILY=7 \
  TIMELINE_LIMIT_WEEKLY=2 \
  TIMELINE_LIMIT_MONTHLY=0 \
  TIMELINE_LIMIT_YEARLY=0 \
  NUMBER_CLEANUP=yes \
  NUMBER_LIMIT=50 \
  NUMBER_LIMIT_IMPORTANT=20 \
  ALLOW_USERS="$ADMIN_USER" \
  SYNC_ACL=yes
sudo snapper -c root get-config | grep -E "TIMELINE_(CREATE|CLEANUP|LIMIT)|NUMBER_(CLEANUP|LIMIT)|ALLOW_USERS|SYNC_ACL"

Asserted against /etc/snapper/configs/root rather than against snapper get-config. The pretty-printed table separates columns with │ (U+2502 box-drawing), not an ASCII pipe, so the obvious awk -F'|' silently matches nothing and every lookup returns empty — which is how the first version of this block "failed" against a config that was in fact correct. The config file is plain KEY="value" and is the authoritative artefact.

verify: snapper-retention
get() { sudo awk -F= -v k="$1" '$1 == k { gsub(/"/, "", $2); print $2 }' \
        /etc/snapper/configs/root; }
for pair in TIMELINE_CREATE=yes TIMELINE_CLEANUP=yes TIMELINE_LIMIT_HOURLY=12 \
            TIMELINE_LIMIT_DAILY=7 TIMELINE_LIMIT_WEEKLY=2 \
            TIMELINE_LIMIT_MONTHLY=0 TIMELINE_LIMIT_YEARLY=0 \
            NUMBER_CLEANUP=yes NUMBER_LIMIT=50 NUMBER_LIMIT_IMPORTANT=20 \
            SYNC_ACL=yes; do
  k="${pair%%=*}"; want="${pair#*=}"; got="$(get "$k")"
  test "$got" = "$want" || { echo "$k is '$got', expected '$want'"; exit 1; }
done
test "$(get ALLOW_USERS)" = "$ADMIN_USER" \
  || { echo "ALLOW_USERS is '$(get ALLOW_USERS)', expected '$ADMIN_USER'"; exit 1; }
echo "retention policy applied"

Timers

snapper-timeline.timer creates the scheduled snapshots; snapper-cleanup.timer applies the retention policy above. Without the cleanup timer the limits are inert and nothing is ever deleted.

runs: snapper-timers
sudo systemctl enable --now snapper-timeline.timer snapper-cleanup.timer
systemctl list-timers --no-pager snapper-\* || true
verify: snapper-timers
for t in snapper-timeline.timer snapper-cleanup.timer; do
  test "$(systemctl is-enabled "$t")" = "enabled" || { echo "$t not enabled"; exit 1; }
  test "$(systemctl is-active  "$t")" = "active"  || { echo "$t not active";  exit 1; }
done
echo "snapper timeline and cleanup timers enabled and active"

The pacman hooks

snap-pac works by dropping alpm hooks. Assert they are actually present rather than trusting that installing the package was enough.

verify: snap-pac-hooks
hooks=$(sudo find /usr/share/libalpm/hooks -name '*snap-pac*' -o -name '*snapper*' 2>/dev/null | sort)
test -n "$hooks" || { echo "no snap-pac alpm hooks found"; exit 1; }
printf '%s\n' "$hooks"

adm brackets every invocation

snap-pac above covers pacman transactions and nothing else. A runbook does more than install packages: it writes config files from heredocs, enables units, installs binaries into /usr/local/bin, edits /etc. A runbook that failed halfway therefore left a partly-changed box bracketed only around whichever part happened to be a package install.

So adm run and adm replay take a pre/post pair of their own, around the whole invocation. The invocation is the right unit because it is the one the operator agreed to: adm run is what sits behind the single approval prompt in 070, so one confirmation earns one revert point. adm verify does not snapshot — it is a read-only health check, and the bracketing is wrapped around run and replay rather than around the shared inner function, so it is unreachable from verify rather than merely unused. -n skips it; --no-snapshot skips it on purpose.

The pair is tagged number with userdata adm=run, matching snap-pac's shape so that one convention covers every snapshot on the box:

57 | pre  |    | ... | number | adm run 065-system-utilities.md --only btop | adm=run
58 | post | 57 | ... | number | adm run 065-system-utilities.md --only btop | adm=run

snapper status 57..58 is then the file-level truth about what that runbook did, sitting next to the prose that claimed what it intended.

Whether EMPTY_PRE_POST_CLEANUP reaps these is unverified. Before the filter below, an adm pair could never be empty: adm appends to hosts/<box>/log/journal.md and writes a run log under hosts/<box>/log/runs/ while the invocation it is bracketing is still going, so even a perfectly idempotent run carried modified files. Measured, not assumed — the first idempotent run under this feature produced a seven-line diff of pure bookkeeping.

With the filter in place an idempotent run reports nothing changed. Whether snapper's empty-pair cleanup consults that filtered comparison or the raw one is not something this runbook has established, and EMPTY_PRE_POST_MIN_AGE is an hour, so the answer arrives by observation rather than by a test here: if idempotent pairs start vanishing on their own, it uses the filtered view. Until that is known, assume these pairs accumulate until NUMBER_LIMIT pushes the oldest out — and note that limit is shared with snap-pac.

Filtering adm's own bookkeeping out of the diff

Those same writes bury the signal. Unfiltered, status across an adm pair opens with adm's journal and run logs and journald's binary journals, before anything the runbook actually did.

Snapper filters fix the readability without touching the snapshots: patterns in /etc/snapper/filters/*.txt are ignored when snapper computes a difference, while the files themselves stay in the snapshot — so restoring one by hand out of /.snapshots/<n>/snapshot/ is unaffected. The shipped base.txt is minimal and covers neither case.

There is deliberately no /home/* pattern here. A filter would also hide admin's home, which is system state that a restore must cover — and an actual person's home does not need one, because 045 makes it a subvolume and snapshots do not recurse into those. Exclusion of user data happens one level down, structurally.

What that does not mean is that filters are cosmetic. Anything built on a computed diff inherits them, and adm undo is built on one — so a filtered path is out of scope for an undo as well, which is desirable for journald and would be surprising for anything else. adm undo therefore passes the exact path list it printed rather than letting undochange recompute the set; see below.

snapperd reads the filters once, at startup. Writing the file changes nothing until the daemon restarts, and because it is D-Bus activated and exits when idle, it eventually picks the file up on its own — which is the worst of both worlds, since the filter appears not to work and then starts working later for no visible reason. This cost a confused ten minutes: the file was correct, the diff was unchanged, and snapper status kept listing the very paths that had just been excluded. So the block restarts it explicitly, with try-restart so that a box where the daemon is not running is left alone rather than having one started for no reason.

runs: snapper-filters
sudo install -d -m 0755 /etc/snapper/filters
sudo install -m 0644 /dev/stdin /etc/snapper/filters/adm.txt <<EOF
# Managed by $ADM_ROOT/runbooks/040-btrfs-snapshots.md -- overwritten on replay.
#
# Ignored when snapper computes a diff. Still present in the snapshot, so
# restoring any of these from one is unaffected.
#
# adm appends to its journal and writes a run log while the invocation it is
# bracketing is still running, so these turn up in every adm pair and never
# mean anything.
$ADM_ROOT/hosts/*/log/*
# journald writes continuously, so this lands in every pair on the box --
# including snap-pac's, which is why it is not an adm-specific problem.
/var/log/journal/*
# snapper logs the very operation that creates the pair, so it is guaranteed
# to differ inside its own snapshots. pacman logs every invocation including
# the ones that decide there is nothing to do, which is exactly what an
# idempotent runbook does all day.
/var/log/snapper.log
/var/log/pacman.log
EOF
cat /etc/snapper/filters/adm.txt
# Filters are read at daemon start, so the file alone does nothing. try-restart
# rather than restart: if snapperd is not running there is nothing to reload,
# and the next D-Bus activation reads the file anyway.
sudo systemctl try-restart snapperd.service
verify: snapper-filters
f=/etc/snapper/filters/adm.txt
sudo test -f "$f" || { echo "$f missing"; exit 1; }
sudo grep -qxF "$ADM_ROOT/hosts/*/log/*" "$f" \
  || { echo "adm's log dir is not filtered out of diffs"; exit 1; }
sudo grep -qxF "/var/log/journal/*" "$f" \
  || { echo "journald is not filtered out of diffs"; exit 1; }

# Assert the effect, not just the file. Fields 3, 6 and 7 are number, type and
# pre-number, all of which precede the description -- which may itself contain
# commas, since snap-pac uses the pacman command line. userdata is last, hence
# $NF.
# `read` returns non-zero at end of input, and these blocks run under
# `set -e`, so on a box with no adm pair yet this line ended the block before
# either branch below could run. That made the fallback unreachable and the
# comment explaining it wrong: it described handling a case it never saw.
# A fresh box is exactly where there is no pair -- the block installing
# snapper ran moments earlier -- and running 040 a second time succeeds,
# which is why an established box never showed it.
#
# The same shape as `grep -c` exiting 1 to report a count of zero, which did
# this to 015's encrypt-env. A command that says "nothing" by failing is a
# command that needs its own failure handled.
pre="" post=""
read -r pre post < <(sudo snapper --csvout --config root list 2>/dev/null \
  | awk -F, '$6=="post" && $NF ~ /adm=run/ { p=$7; n=$3 } END { if (n) print p, n }') \
  || true
if [[ -n "${pre:-}" && -n "${post:-}" ]]; then
  noise="$(sudo snapper --config root status "$pre..$post" 2>/dev/null \
    | grep -E "$ADM_ROOT/hosts/[^/]*/log/|/var/log/journal/|/var/log/snapper\.log" || true)"
  if [[ -n "$noise" ]]; then
    echo "filter is not in effect for $pre..$post:"
    printf '%s\n' "$noise" | sed 's/^/  /'
    echo "snapperd reads filters at startup -- try: systemctl try-restart snapperd"
    exit 1
  fi
  echo "filters installed and in effect; adm pair $pre..$post is free of bookkeeping"
else
  # Conditional on purpose: a freshly rebuilt box has not yet run adm under a
  # snapshot, and failing for want of history would be failing for the wrong
  # reason.
  echo "filters installed; no adm pair recorded yet to check them against"
fi

Named checkpoints

adm checkpoint "before-fuzz-upgrade"    # a snapshot nothing reclaims
adm restore before-fuzz-upgrade         # preview; refuses without --yes
adm restore before-fuzz-upgrade --yes   # revert to it, then verify

A checkpoint is permanent because it carries no cleanup algorithm, not because it has any special status. The hourly cleanup is /usr/lib/snapper/systemd-helper --cleanup, which works per algorithm — number and timeline — so a snapshot tagged with neither is never a candidate and cannot be pushed out by NUMBER_LIMIT. The corollary is that it will never leave on its own either: checkpoints are the operator's to delete, which is why adm snapshots lists them in their own section rather than mixed in with the pairs. An expiring snapshot you forget costs a slot; a permanent one you forget costs a disk.

adm restore is adm undo with the two snapshot numbers chosen differently, and it goes through every one of the same guards: prints the diff, refuses without --yes, declines to run when the filters are not in effect, reverts exactly the paths it printed, checks the result against the live filesystem, and verifies afterwards.

One mechanical detail worth recording, because it was a deliberate choice rather than an accident. Restoring needs two snapshots to diff, and the second is the safety snapshot adm restore takes anyway — so the revert is undochange <checkpoint>..<safety>, two real snapshots in the form the manual documents. ..0, meaning the live filesystem, appears only in the read-only preview and the post-condition check. status N..0 is demonstrably supported; whether undochange accepts the same was never established, so nothing depends on it.

A checkpoint is not a rollback point in the VM sense. Restoring one replays file differences, so it cannot repair a box that will not boot — undochange needs a running system to execute in. See DECISIONS.md #006 for what would be required and why this box deliberately does not have it.

And a checkpoint excludes nothing by itself. Exclusion comes from the filter file above, which scopes every computed diff, so adding /home/* keeps home directories out of both the diff and any restore. That works because restore is file-level. It would not work under a true rollback, where a subvolume is swapped whole and there is no diff to filter — which is the coupling #006 exists to record.

Listing, undoing and pruning

adm checkpoint "<name>"     # a named snapshot that never expires
adm restore <name> --yes    # revert the box to one
adm snapshots              # checkpoints, and adm's pairs
adm snapshots --changes     # ...and how many paths each altered
adm undo 65..66             # show what reverting would do; refuses here
adm undo 65..66 --yes       # revert it, then verify
adm prune --empty           # pairs whose invocation changed nothing
adm prune --keep 5 --yes    # keep the newest five pairs

Nothing here deletes or reverts without --yes, and prune with no selector is an error rather than a default, so there is no spelling of it that quietly removes everything.

adm undo wraps snapper undochange and runs adm verify afterwards, on purpose and not optionally. A rollback reverts the box behind its own documentation, so a verify failure after one is the expected outcome and the useful one: it names the runbook that now claims something untrue. Re-run that runbook, or amend it to record that the change was abandoned and why.

Three properties of it are deliberate, and each exists because the obvious implementation was not good enough:

Two limits worth knowing before relying on any of this:

End-to-end proof

The blocks above assert that snapshots are configured. This one proves the thing you actually care about: that a deleted file can be recovered.

It writes a canary, snapshots, deletes the canary, restores it from the snapshot, compares content, then removes the snapshot and the canary. Self cleaning, so it is safe on every replay — and it is a run block rather than a verify block because it mutates, which verify blocks must never do.

If this block ever fails, the feature is broken no matter what the configuration says.

runs: snapshot-roundtrip
canary=/var/tmp/adm-snapshot-canary
token="canary-$(cat /proc/sys/kernel/random/boot_id)"
printf '%s\n' "$token" | sudo tee "$canary" >/dev/null

num=$(sudo snapper -c root create --print-number \
        --description "adm: snapshot round-trip self-test")
echo "created snapshot #$num"

sudo rm -f "$canary"
test ! -e "$canary" || { echo "canary survived deletion?"; exit 1; }

src="/.snapshots/$num/snapshot$canary"
sudo test -f "$src" || { echo "canary not present in snapshot #$num at $src"; exit 1; }
sudo cp -a "$src" "$canary"

restored="$(sudo cat "$canary")"
test "$restored" = "$token" \
  || { echo "restored content mismatch: '$restored' != '$token'"; exit 1; }
echo "restored from snapshot #$num and content matches"

sudo snapper -c root delete "$num"
sudo rm -f "$canary"
echo "cleaned up: snapshot #$num and canary removed"

Using it

snapper -c root list                       # every snapshot
snapper -c root status 42..43              # what changed between two
snapper -c root diff  42..43 /etc/fstab    # how a file changed
sudo cp /.snapshots/42/snapshot/etc/foo /etc/foo    # restore one file
snapper -c root create -d "before something risky"  # manual, before risk

After a regrettable pacman -Syu, snapper -c root list shows the pre/post pair that snap-pac made, and status between them is the exact set of files the transaction touched.

Next, if this proves useful

045 — User homes are subvolumes of their own

Optional — a box opts in by adding 045 to OPTIONAL_RUNBOOKS in its <box>.env.

Give each person's home its own subvolume and its own snapshots, so rolling the system back does not take their work with it and they keep control of their own history.

Every snapshot 040 takes covers the root tree, and that included /home entirely — so adm undo or adm restore would revert a person's files along with the system's. For the admin account that is correct and deliberate: admin is the operator, its home holds the box's own state including the age key from 015, and it belongs inside the system's snapshots.

For anyone else with an account it is wrong twice over. Their files are not the box's business, and they should be able to snapshot and roll back their own home without being able to touch the system's.

How the exclusion works

btrfs snapshots do not recurse into child subvolumes. So a /home/<user> that is its own subvolume is excluded from every root snapshot — adm pairs, checkpoints, timeline and snap-pac alike — structurally, with nothing to configure and nothing to forget. It is the same property DECISIONS.md #006 leans on when it says exclusion requires a subvolume rather than a pattern.

/home itself stays an ordinary directory. Only the homes beneath it are subvolumes, which is all the exclusion needs and avoids migrating anything.

Note what this means for /etc/snapper/filters/adm.txt: there is deliberately no /home/* pattern in it. A filter would also hide admin's home, which is exactly the system state restores must cover, and non-admin homes need no filter because they are excluded one level down.

Who is in scope

$MACHINE_USERS from <box>.env, space separated, each entry name:uid:

MACHINE_USERS="ryan:2001 buildbot:2002"

An empty list is normal and these blocks do nothing.

The uid is required, not optional. useradd would otherwise take the next free number from UID_MIN, which makes it a function of creation order — and this runbook is the one place where that bites hardest. A machine user's home is deliberately outside the system's snapshots, so on a rebuild their data comes from their own backup, and a tar or btrfs send stream carries numeric owners. A box that hands out a different number restores those files to the wrong account. So 045's "a rebuild does not bring it back" is worse than it reads unless the number is pinned: the data would be absent and the ownership it returned with would be unpredictable.

It also matters to the reproduction test owed in DECISIONS.md #001. hosts/<box>/facts/users.txt records the whole passwd line, so a replay that numbers accounts differently shows up as drift — detectable, which is good, but the drift is a symptom of a decision nobody wrote down.

The group is created with the same number, so uid == gid and neither depends on order. A uid already held by a different account is refused, and an existing account whose uid disagrees with the list is refused too rather than renumbered: changing the uid of an account that owns files is a manual job.

The name is deliberately not HUMAN_USERS, which it was for about an hour. The line this runbook draws is not between people and software — it is between the operator account, whose home is part of the box, and every other account that owns its own home and its own history. A service account, a CI runner or an agent with somewhere to keep state belongs on this list exactly as much as a person does, and for the same reason: a system rollback should not reach into it, and its owner should be able to roll it back without reaching into the system.

$ADMIN_USER must not appear in it, and the block refuses if it does. A box that excluded the admin home would quietly stop snapshotting its own configuration and secrets while appearing to work.

Order matters: subvolume before account

The subvolume has to exist before the home is populated. Converting a populated directory into a subvolume means copying it out, replacing it and copying it back with the user logged out — not something a replay should attempt unattended. So this creates the subvolume and then the account, and if it finds an existing user whose home is a plain directory it refuses and says so. That conversion is a decision for a human at a keyboard.

pam_snapper ships with snapper and automates roughly this, including a pam_snapper_homeconvert.sh. It is not used here: its helpers call /usr/sbin/pam-config, which is SUSE's and does not exist on Arch, and its PAM hooks snapshot on every login, which is noise rather than a feature. The idea is borrowed; the code is not.

What this does not do

Excluding a home from the system's snapshots means a system rollback leaves it alone, which is the point. It also means a rebuild does not bring it back: replaying this runbook recreates the account and an empty subvolume, nothing else. User data is the user's own problem, and the hypervisor tier in DECISIONS.md #003 covers the disk rather than any particular file's history.

runs: user-homes
users="${MACHINE_USERS:-}"
if [[ -z "${users// /}" ]]; then
  echo "MACHINE_USERS is empty; no per-user homes to manage"
  exit 0
fi

for entry in $users; do
  u="${entry%%:*}"
  uid="${entry#*:}"
  if [[ "$u" == "$entry" || -z "$uid" || ! "$uid" =~ ^[0-9]+$ ]]; then
    echo "MACHINE_USERS entry '$entry' carries no numeric uid."
    echo "Write it as name:uid. The uid is pinned deliberately -- see this runbook."
    exit 1
  fi
  if [[ "$u" == "$ADMIN_USER" ]]; then
    echo "$ADMIN_USER is the admin account: its home is system state and has"
    echo "to stay inside the root snapshots. Remove it from MACHINE_USERS."
    exit 1
  fi
  home="/home/$u"
  cfg="home-$u"

  # A uid already spoken for is a collision, not a no-op.
  owner="$(getent passwd "$uid" 2>/dev/null | cut -d: -f1 || true)"
  if [[ -n "$owner" && "$owner" != "$u" ]]; then
    echo "uid $uid already belongs to '$owner', not '$u'"
    exit 1
  fi
  # An account that owns files cannot be renumbered safely, so say so.
  if id -u "$u" >/dev/null 2>&1 && [[ "$(id -u "$u")" != "$uid" ]]; then
    echo "$u exists with uid $(id -u "$u") but MACHINE_USERS declares $uid."
    echo "Renumbering an account that owns files is a manual job and this"
    echo "runbook will not attempt it."
    exit 1
  fi

  if id -u "$u" >/dev/null 2>&1 \
     && ! sudo btrfs subvolume show "$home" >/dev/null 2>&1; then
    echo "$u already exists and $home is a plain directory."
    echo "Converting a populated home needs the user logged out and the data"
    echo "copied out and back; this runbook will not do that unattended."
    exit 1
  fi

  sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
    || sudo btrfs subvolume create "$home"
  # gid is pinned equal to uid, so neither number depends on creation order.
  getent group "$u" >/dev/null 2>&1 || sudo groupadd --gid "$uid" "$u"
  id -u "$u" >/dev/null 2>&1 \
    || sudo useradd --uid "$uid" --gid "$u" --home-dir "$home" \
                    --no-create-home --shell /bin/bash "$u"
  # useradd declines to populate an existing directory, so skel is copied here
  # -- but only into an empty home, or a replay would overwrite the user's own
  # dotfiles with the defaults every time.
  if [[ -z "$(sudo ls -A "$home")" ]]; then
    sudo cp -a /etc/skel/. "$home"/
  fi
  sudo chown -R "$u:$u" "$home"
  sudo chmod 0700 "$home"

  sudo snapper --config "$cfg" list >/dev/null 2>&1 \
    || sudo snapper --config "$cfg" create-config "$home"
  # TIMELINE_CREATE is yes by default, which would take hourly snapshots of
  # someone's home without asking. The premise here is that the user decides.
  sudo snapper --config "$cfg" set-config \
    "ALLOW_USERS=$u" "SYNC_ACL=yes" "TIMELINE_CREATE=no"
  echo "$u ($uid:$uid): $home is a subvolume; snapper config '$cfg' is theirs"
done
verify: user-homes
users="${MACHINE_USERS:-}"
declared=""
for entry in $users; do declared="$declared ${entry%%:*}"; done

# Orphans first, because this is the half the run block cannot do. It creates
# and never removes: drop a name from MACHINE_USERS and the account, its home
# and its config stay. Converging by deleting them would destroy exactly the
# data this runbook exists to keep out of the system's reach, so the drift is
# reported and left for a human.
shopt -s nullglob
for f in /etc/snapper/configs/home-*; do
  n="$(basename "$f")"
  n="${n#home-}"
  case " $declared " in *" $n "*) continue ;; esac
  echo "snapper config 'home-$n' exists but '$n' is not in MACHINE_USERS"
  echo "declare it again, or remove the account, its home and the config"
  exit 1
done
shopt -u nullglob

if [[ -z "${users// /}" ]]; then
  echo "MACHINE_USERS is empty; no declared users and no orphans"
  exit 0
fi

for entry in $users; do
  u="${entry%%:*}"
  uid="${entry#*:}"
  home="/home/$u"
  cfg="home-$u"
  id -u "$u" >/dev/null 2>&1 || { echo "$u does not exist"; exit 1; }
  # The pinned numbers, which are the whole reason the uid is in the list.
  test "$(id -u "$u")" = "$uid" \
    || { echo "$u has uid $(id -u "$u"); MACHINE_USERS declares $uid"; exit 1; }
  test "$(id -g "$u")" = "$uid" \
    || { echo "$u has gid $(id -g "$u"); uid and gid are pinned equal"; exit 1; }
  sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
    || { echo "$home is not a subvolume, so root snapshots still contain it"
         exit 1; }
  sudo test -f "/etc/snapper/configs/$cfg" \
    || { echo "no snapper config '$cfg'"; exit 1; }

  # Behaviour, not configuration: the user must be able to work their own
  # config with no privilege at all. That is what ALLOW_USERS and SYNC_ACL are
  # for, and reading the file back would only prove the file was written.
  sudo -u "$u" snapper --config "$cfg" list >/dev/null 2>&1 \
    || { echo "$u cannot use snapper config '$cfg' unprivileged"; exit 1; }

  # And the boundary in the other direction, which is the half that matters.
  if sudo -u "$u" snapper --config root list >/dev/null 2>&1; then
    echo "$u can read the root snapper config; the grant is too broad"
    exit 1
  fi
  echo "$u ($uid:$uid): $home excluded from root snapshots; '$cfg' theirs, root denied"
done

Proving it rather than asserting it

The blocks above do nothing while MACHINE_USERS is empty, which is the normal state of a box with one operator and no people on it — and an untested path is worth little on the day it is finally needed. So this proves the whole mechanism against a throwaway account on every replay, the same way 040's snapshot-roundtrip proves restore rather than asserting configuration.

Three claims, each failing loudly: a root snapshot cannot see inside the home, the user can snapshot their own, and the user cannot reach the system's.

runs: user-home-roundtrip
u=admhometest
home="/home/$u"
cfg="home-$u"

# A previous failure leaves debris on purpose -- loud evidence beats silent
# cleanup -- so start by clearing it.
sudo snapper --config "$cfg" delete-config >/dev/null 2>&1 || true
id -u "$u" >/dev/null 2>&1 && sudo userdel "$u" >/dev/null 2>&1 || true
sudo btrfs subvolume show "$home" >/dev/null 2>&1 \
  && sudo btrfs subvolume delete "$home" >/dev/null || true
sudo rm -rf "$home"

sudo btrfs subvolume create "$home" >/dev/null
sudo useradd --home-dir "$home" --no-create-home --shell /bin/bash "$u"
sudo chown "$u:$u" "$home"
sudo chmod 0700 "$home"
sudo snapper --config "$cfg" create-config "$home"
sudo snapper --config "$cfg" set-config \
  "ALLOW_USERS=$u" "SYNC_ACL=yes" "TIMELINE_CREATE=no"

# 1. the exclusion itself. No cleanup algorithm on this snapshot, so nothing
# can reap it mid-test.
num=$(sudo snapper --config root create --print-number \
        --description "adm: user-home exclusion self-test")
canary="$home/exclusion-canary"
printf 'canary\n' | sudo tee "$canary" >/dev/null
if sudo snapper --config root status "$num..0" | grep -qF "$canary"; then
  echo "FAIL: $canary shows in a root snapshot diff; the home is not excluded"
  exit 1
fi
echo "a root snapshot cannot see inside $home"

# 2. the user's own control, exercised as the user.
snum=$(sudo -u "$u" snapper --config "$cfg" create --print-number \
         --description "roundtrip self-test")
test -n "$snum" || { echo "FAIL: $u could not snapshot their own home"; exit 1; }
echo "$u snapshotted their own home unprivileged (#$snum)"

# 3. and the boundary.
if sudo -u "$u" snapper --config root list >/dev/null 2>&1; then
  echo "FAIL: $u can read the root snapper config"
  exit 1
fi
echo "$u cannot reach the system's snapshots"

sudo -u "$u" snapper --config "$cfg" delete "$snum"
sudo snapper --config root delete "$num"
# delete-config removes the config from snapper's registry in
# /etc/conf.d/snapper and deletes the .snapshots subvolume, but leaves
# /etc/snapper/configs/<name> behind. snapper then reports the config as
# unknown while the file sits in /etc as undeclared drift -- which this block
# produced twice before anyone looked, while printing "cleaned up". So the
# file is removed explicitly and its absence asserted, because a cleanup that
# is trusted rather than checked is how the debris got there.
sudo snapper --config "$cfg" delete-config
sudo rm -f "/etc/snapper/configs/$cfg"
sudo test -e "/etc/snapper/configs/$cfg" \
  && { echo "FAIL: /etc/snapper/configs/$cfg survived cleanup"; exit 1; } || true
sudo userdel "$u"
sudo btrfs subvolume delete "$home" >/dev/null
sudo rm -rf "$home"
test ! -e "$home" || { echo "FAIL: $home survived cleanup"; exit 1; }
echo "cleaned up and checked: $u, $home, config '$cfg', snapshot #$num"

Using it

A person with an account snapshots their own home with no privilege:

snapper -c home-ryan create -d "before the big refactor"
snapper -c home-ryan list
snapper -c home-ryan status 3..0
snapper -c home-ryan undochange 3..0

Their snapshots carry no cleanup algorithm, so nothing reclaims them; they expire when their owner says so. adm snapshots, adm undo and adm restore do not touch any of this — they work the root config only.

050 — Boot into snapshots (grub-btrfs)

Optional — a box opts in by adding 050 to OPTIONAL_RUNBOOKS in its <box>.env.

Add GRUB menu entries for every btrfs snapshot, so a box that will not boot can be booted as it was to inspect or repair it.

Depends on 040 for the snapshots themselves, and on GRUB — not on a particular firmware. BIOS and UEFI both work, because this runbook only ever calls grub-mkconfig, never grub-install, and Arch puts grub.cfg at /boot/grub/grub.cfg on both. What it cannot work with is systemd-boot, which has no snapshot-boot equivalent.

What this is for, and what it is not

The recovery tier is the hypervisor (DECISIONS.md #003) — whole-disk, off-box, authoritative. This is the cheap tier: booting a snapshot is non-destructive, takes one reboot, and leaves the live filesystem untouched, which makes it the right first move for "what did that upgrade break?" A hypervisor restore is the right move once you know.

So this is a diagnostic convenience, not a durability feature.

Read this before relying on it: selection needs the console

Generating snapshot entries is the easy half. Choosing one is the half that constrains this box.

The obvious headless mechanism is grub-reboot, which sets a one-shot next_entry in grubenv so the next boot lands on a chosen entry and all later boots revert. It does not work here, and using it would be a trap.

GRUB's generated config has the machinery, but it needs env_block set to reach grubenv by raw block list, bypassing the filesystem driver:

if [ "${env_block}" ] ; then
  set env_block="(${root})${env_block}"      # <- only ever prefixes; never assigned
  load_env -f "${env_block}"
fi
...
   if [ "${env_block}" ] ; then
     save_env -f "${env_block}" next_entry   # blocklist write: would work
   else
     save_env next_entry                     # filesystem write: fails on btrfs
   fi

env_block is never assigned on this box, so the second branch is taken — and GRUB's btrfs module is read-only. The blocklist workaround is not available either, because grubenv is stored inline in btrfs metadata rather than in its own extent:

File size of /boot/grub/grubenv is 1024 (1 block of 4096 bytes)
   0:  0.. 4095:  0.. 4095:  4096:  last,encoded,not_aligned,inline,eof

The failure mode is nasty rather than obvious: grub-reboot would set next_entry (that write is done by the OS, which can write btrfs fine), GRUB would boot that entry, then fail silently to clear it — so the box would boot that snapshot on every subsequent boot until someone noticed. On a machine administered as though it has no console, that is how you lose a box.

So selection is via the GRUB menu, over the serial console. This image is already wired for it — GRUB_TERMINAL="serial console", GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200", and the kernel gets console=ttyS0,115200. The hypervisor just has to expose the serial port.

That makes this runbook an honest exception to the no-console policy: it installs a tool whose use requires console access. Worth having anyway, because the situation where you want it — a box that will not boot — is one where you are already at the console.

Preconditions

verify: grub-btrfs-preconditions
test "$(findmnt -no FSTYPE /)" = "btrfs" || { echo "root is not btrfs"; exit 1; }

# GRUB, not the firmware, is the requirement. BIOS and UEFI both work: this
# runbook only ever calls grub-mkconfig, never grub-install, and Arch puts
# grub.cfg at /boot/grub/grub.cfg either way.
if ! command -v grub-mkconfig >/dev/null || [[ ! -d /boot/grub ]]; then
  echo "GRUB is not the bootloader on this box."
  if sudo bootctl is-installed >/dev/null 2>&1; then
    echo "It appears to use systemd-boot, which has no snapshot-boot"
    echo "equivalent: grub-btrfs generates GRUB menu entries and nothing else"
    echo "does. Either install GRUB, or accept that boot-into-snapshot is"
    echo "unavailable here -- 040's file-level restore still works, and the"
    echo "hypervisor tier still covers whole-VM rollback."
  fi
  exit 1
fi
sudo test -f /boot/grub/grub.cfg || { echo "no /boot/grub/grub.cfg"; exit 1; }

# A snapshot must contain the kernel and initramfs it was taken with, or a
# snapshot boot pairs an old root with the current kernel. That holds only
# while /boot is inside the root filesystem -- a UEFI install mounting the ESP
# at /boot breaks it, on otherwise identical hardware.
if boot_src="$(findmnt -no SOURCE /boot 2>/dev/null)"; then
  test "$boot_src" = "$(findmnt -no SOURCE /)" || {
    echo "/boot is a separate filesystem ($boot_src)."
    echo "Snapshots would not contain the kernel they were taken with."
    exit 1; }
fi

sudo btrfs subvolume show /.snapshots >/dev/null 2>&1 \
  || { echo "/.snapshots missing -- run 040 first"; exit 1; }

if [[ -d /sys/firmware/efi ]]; then fw=UEFI; else fw="BIOS"; fi
echo "btrfs root, GRUB $(pacman -Q grub | awk '{print $2}') on $fw, /boot on the root fs, /.snapshots present"

The package

inotify-tools is listed as an optional dependency of grub-btrfs, and is mandatory for anything here: grub-btrfsd is a shell script built around inotifywait, and without it the daemon exits 1 after about eleven milliseconds. Installing grub-btrfs alone gets you a regeneration daemon that never runs.

package why
grub-btrfs the 41_snapshots-btrfs generator and grub-btrfsd
inotify-tools optional per the package, required for the daemon to start at all
runs: grub-btrfs-package
sudo pacman -S --needed --noconfirm grub-btrfs inotify-tools
verify: grub-btrfs-package
pacman -Qi grub-btrfs >/dev/null || { echo "grub-btrfs missing"; exit 1; }
command -v inotifywait >/dev/null \
  || { echo "inotifywait missing -- grub-btrfsd cannot start"; exit 1; }
sudo test -x /etc/grub.d/41_snapshots-btrfs \
  || { echo "snapshot generator script missing"; exit 1; }
echo "grub-btrfs $(pacman -Q grub-btrfs | awk '{print $2}'), inotify-tools present"

Make a booted snapshot usable

Snapper's snapshots are read-only (ro=true), and a read-only root is not much use: systemd, journald and most of userspace expect to write. grub-btrfs ships an initramfs hook that mounts the snapshot read-only and layers a writable tmpfs overlay on top, so the booted system behaves normally and nothing you do touches the snapshot.

Added as a drop-in rather than by editing HOOKS in /etc/mkinitcpio.conf. That file belongs to the mkinitcpio package; editing it invites .pacnew reconciliation forever, and /etc/mkinitcpio.conf.d/ exists precisely so we do not have to. (mkinitcpio here is 42.2; the drop-in directory arrived in 38.)

runs: grub-btrfs-overlayfs
sudo install -d -m 0755 /etc/mkinitcpio.conf.d
sudo install -m 0644 /dev/stdin /etc/mkinitcpio.conf.d/grub-btrfs-overlayfs.conf <<'EOF'
# Managed by /opt/admin/runbooks/050-grub-btrfs.md
# Lets a read-only btrfs snapshot boot as a usable system: the snapshot is
# mounted ro and a writable tmpfs is overlaid, so a snapshot boot cannot
# modify the snapshot. Without this, booting a snapper snapshot yields a
# read-only root and a broken userspace.
HOOKS+=(grub-btrfs-overlayfs)
EOF
sudo mkinitcpio -P
verify: grub-btrfs-overlayfs
sudo test -f /etc/mkinitcpio.conf.d/grub-btrfs-overlayfs.conf \
  || { echo "mkinitcpio drop-in missing"; exit 1; }
# The hook must actually be inside the built image, not merely configured.
sudo lsinitcpio /boot/initramfs-linux.img 2>/dev/null \
  | grep -q "grub-btrfs-overlayfs" \
  || { echo "overlayfs hook is NOT present in /boot/initramfs-linux.img"; exit 1; }
echo "grub-btrfs-overlayfs hook present in the initramfs"

A menu you can actually catch

GRUB_TIMEOUT is 1 second on this image. That is sensible for a server that always boots the same thing, and useless for picking a snapshot over a serial line. Five seconds is long enough to interrupt and short enough not to annoy.

The cost is real and worth stating: every boot gets four seconds slower. That is the price of snapshot booting being possible at all here, given grub-reboot is unavailable.

Again a drop-in — grub-mkconfig sources /etc/default/grub.d/*.cfg, so /etc/default/grub stays pristine and .pacnew-free.

runs: grub-menu-timeout
sudo install -d -m 0755 /etc/default/grub.d
sudo install -m 0644 /dev/stdin /etc/default/grub.d/10-snapshot-menu.cfg <<'EOF'
# Managed by /opt/admin/runbooks/050-grub-btrfs.md
# 1s (the image default) is too short to interrupt over a serial console, and
# selecting a snapshot entry by hand is the only way to boot one on this box:
# grub-reboot cannot work, because GRUB cannot write grubenv on btrfs.
GRUB_TIMEOUT=5
GRUB_TIMEOUT_STYLE=menu
EOF
cat /etc/default/grub.d/10-snapshot-menu.cfg
verify: grub-menu-timeout
sudo test -f /etc/default/grub.d/10-snapshot-menu.cfg || { echo "drop-in missing"; exit 1; }
echo "timeout drop-in present (effect asserted in grub-regenerate)"

Generate the menu

runs: grub-regenerate
sudo grub-mkconfig -o /boot/grub/grub.cfg
verify: grub-regenerate
cfg=/boot/grub/grub.cfg
sudo grep -q "set timeout=5" "$cfg" \
  || { echo "timeout drop-in did not take effect"; exit 1; }
sudo grep -qi "snapshots" "$cfg" \
  || { echo "no snapshot entries in grub.cfg -- is 41_snapshots-btrfs executable?"; exit 1; }
n=$(sudo grep -cE "^\s*menuentry" "$cfg")
echo "grub.cfg: timeout 5, $n menuentries, snapshot submenu present"

The regeneration daemon

Snapshot entries go stale the moment a snapshot is created or deleted — which, with snap-pac and an hourly timeline, is constantly. grub-btrfsd watches the snapshot directory and regenerates the menu on change.

No unit override is needed: the shipped service already watches the snapper location (ExecStart=/usr/bin/grub-btrfsd --syslog /.snapshots).

The start is followed by a settle-and-recheck, which is not padding. systemctl is-active immediately after enable --now returned active for a daemon that had already exited — systemd had not yet reaped it. The missing inotify-tools above was therefore reported as a success at run time and only caught by the next adm verify. Any long-running service is worth re-checking after a pause, or the block cheerfully certifies a corpse.

runs: grub-btrfsd
sudo systemctl enable --now grub-btrfsd.service
sleep 2
if ! systemctl is-active --quiet grub-btrfsd.service; then
  echo "grub-btrfsd did not stay running:"
  systemctl status grub-btrfsd.service --no-pager -l | tail -8
  exit 1
fi
systemctl show -p MainPID -p ActiveState --value grub-btrfsd.service | paste -sd' '
verify: grub-btrfsd
test "$(systemctl is-enabled grub-btrfsd.service)" = "enabled" \
  || { echo "grub-btrfsd not enabled"; exit 1; }
test "$(systemctl is-active grub-btrfsd.service)" = "active" \
  || { echo "grub-btrfsd not running: $(systemctl is-failed grub-btrfsd.service)"; exit 1; }
pid="$(systemctl show -p MainPID --value grub-btrfsd.service)"
test "${pid:-0}" -gt 0 || { echo "grub-btrfsd has no main process"; exit 1; }
test -d "/proc/$pid" || { echo "grub-btrfsd main pid $pid is not alive"; exit 1; }
echo "grub-btrfsd enabled, active, pid $pid, watching /.snapshots"

Assert the snapshot entries would actually boot a snapshot

A generated entry inherits the normal kernel command line and then appends its own rootflags, so every snapshot entry carries two:

linux "/.snapshots/6/snapshot/boot/vmlinuz-linux" root=UUID=a51fe3b5… \
  rootflags=compress-force=zstd console=tty0 console=ttyS0,115200 \
  rootflags=subvol=".snapshots/6/snapshot"

The kernel's rootflags= handler copies the value into a single buffer, so repeated occurrences overwrite and the last one wins. Today the subvol= flag is last, so the snapshot is what gets mounted — correct, but correct by ordering, which is a thin guarantee. If GRUB_CMDLINE_LINUX_DEFAULT ever grew its own trailing rootflags, every snapshot entry would quietly boot the live root while still being labelled as a snapshot. A booted snapshot that is secretly the live system is the worst possible outcome for a tool you reach for during an incident.

So assert it rather than trust it: for every snapshot entry, the last rootflags must select a subvolume.

One consequence of last-wins, worth knowing and not worth fixing: compress-force=zstd is dropped during a snapshot boot. It does not matter, because the overlayfs hook sends all writes to a tmpfs — nothing is written to btrfs to compress.

verify: snapshot-entries-bootable
cfg=/boot/grub/grub-btrfs.cfg
sudo test -s "$cfg" || { echo "$cfg missing or empty -- run grub-mkconfig"; exit 1; }
n=0; bad=0
while IFS= read -r line; do
  n=$((n+1))
  last="$(grep -oE 'rootflags=[^ ]*' <<<"$line" | tail -1)"
  case "$last" in
    *subvol=*) : ;;
    *) echo "entry $n: last rootflags does not select a subvolume: ${last:-<none>}"
       bad=$((bad+1)) ;;
  esac
  grep -q 'linux "/\.snapshots/' <<<"$line" \
    || { echo "entry $n: kernel is not loaded from a snapshot path"; bad=$((bad+1)); }
done < <(sudo grep -E '^[[:space:]]+linux[[:space:]]' "$cfg")
test "$n" -gt 0 || { echo "no snapshot boot entries found in $cfg"; exit 1; }
test "$bad" -eq 0 || { echo "$bad problem(s) across $n entries"; exit 1; }
echo "$n snapshot entries, each loading its kernel from the snapshot and ending with a subvol rootflags"

Guard against the grub-reboot trap

If anyone ever runs grub-reboot on this box — reasonably, since it works nearly everywhere else — next_entry gets written and GRUB can never clear it, so the box boots that entry on every boot thereafter. The symptom is baffling if you do not know to look.

This turns that trap into a detectable condition, checked on every adm verify, with the fix in the failure message.

verify: no-stale-next-entry
env_list="$(sudo grub-editenv /boot/grub/grubenv list 2>/dev/null || true)"
if grep -q '^next_entry=' <<<"$env_list"; then
  echo "DANGER: next_entry is set in grubenv:"
  grep '^next_entry=' <<<"$env_list"
  echo
  echo "GRUB cannot clear this on btrfs, so this box will boot that entry on"
  echo "EVERY boot until it is removed. Clear it with:"
  echo "  sudo grub-editenv /boot/grub/grubenv unset next_entry"
  exit 1
fi
echo "no stale next_entry in grubenv"

Using it

  1. Open the VM's serial console at the hypervisor.
  2. Reboot. Interrupt the five-second countdown.
  3. Pick Arch Linux snapshots, then the snapshot by date and description — snap-pac labels its entries with the pacman command line that caused them, which is usually enough to identify the one you want.
  4. The snapshot boots read-only with a writable overlay. Changes you make are discarded on the next reboot, so investigate freely.
  5. To recover: copy what you need out, reboot into the normal entry, and put it back. Or decide the damage warrants a hypervisor restore.

Note what a snapshot boot does not include: nested subvolumes appear empty, because btrfs snapshots are not recursive. In particular /swap is empty, so swap activation fails with a harmless journal error, and /.snapshots is empty — you cannot reach other snapshots from inside a booted one.

060 — Development packages

Required — every box runs this.

The tooling this box needs to be a workstation rather than a server.

Everything up to 050 was about making the box manageable. This is the first runbook about making it useful — and specifically about making it a host for an agent harness (070), which is what this machine exists for.

Why these, and why a table

Every package earns a line explaining why it is here. That rule exists because package lists rot into archaeology otherwise: six months on, nobody knows whether fzf is load-bearing or was installed once to try something. A name with no reason attached cannot be safely removed, so it never is.

package why
base-devel compilers, make, fakeroot. Needed by anything built from source, and a prerequisite for AUR packages if that ever becomes necessary
tmux detachable sessions. The load-bearing one for 070: an agent run that outlives an SSH connection has to be detachable, or every dropped link kills the work
ripgrep fast search. Already pulled in as an opencode dependency, listed explicitly because this repo also relies on it directly
fd fast file discovery; the companion to ripgrep that agents and humans both reach for
fzf interactive filtering, including over adm list output
neovim an editor on the box. sops needs $EDITOR to edit encrypted files in place
man-db manual pages. An agent that can read man sops makes fewer things up
bash-completion completions generally, and opencode ships a bash completion that is inert without it
github-cli gh, for forge interaction that is awkward over plain git

Deliberately not here: nodejs, npm, bun. opencode is a self-contained binary depending only on curl, glibc, icu, ripgrep and tar, so a JavaScript runtime would be dead weight installed on a guess. Add one when something actually needs it, with a line in this table saying what.

runs: dev-packages
sudo pacman -S --needed --noconfirm \
  base-devel tmux ripgrep fd fzf neovim man-db bash-completion github-cli
verify: dev-packages
missing=()
for p in base-devel tmux ripgrep fd fzf neovim man-db bash-completion github-cli; do
  pacman -Qi "$p" >/dev/null 2>&1 || pacman -Qg "$p" >/dev/null 2>&1 || missing+=("$p")
done
test "${#missing[@]}" -eq 0 || { echo "missing: ${missing[*]}"; exit 1; }
# Commands, not just packages -- a package can install and still not be usable.
for c in tmux rg fd fzf nvim man gh; do
  command -v "$c" >/dev/null || { echo "command not on PATH: $c"; exit 1; }
done
echo "dev packages present: tmux $(tmux -V | awk '{print $2}'), rg $(rg --version | head -1 | awk '{print $2}'), nvim $(nvim --version | head -1 | awk '{print $2}'), gh $(gh --version | head -1 | awk '{print $3}')"

An editor for sops

sops <file> decrypts to a temporary file, opens $EDITOR, and re-encrypts on save — the only way to edit a secret without it ever touching the disk in clear. With $EDITOR unset, sops falls back to vi, which exists but is not what is installed here deliberately.

Set at the shell level rather than in <box>.env, because it is a preference of whoever is logged in, not a property of the machine. Written as a drop-in so no package-managed file is edited.

runs: editor-default
sudo install -d -m 0755 /etc/profile.d
sudo install -m 0644 /dev/stdin /etc/profile.d/10-editor.sh <<'EOF'
# Managed by /opt/admin/runbooks/060-dev-packages.md
# sops opens $EDITOR to edit encrypted files in place; without this it falls
# back to vi. Only set if the session has not chosen otherwise.
: "${EDITOR:=nvim}"
: "${VISUAL:=nvim}"
export EDITOR VISUAL
EOF
bash -lc 'echo "EDITOR=$EDITOR"'
verify: editor-default
sudo test -f /etc/profile.d/10-editor.sh || { echo "editor drop-in missing"; exit 1; }
got="$(bash -lc 'echo -n "$EDITOR"')"
test "$got" = "nvim" || { echo "EDITOR is '$got' in a login shell, expected nvim"; exit 1; }
command -v "$got" >/dev/null || { echo "EDITOR=$got is not installed"; exit 1; }
echo "login shells get EDITOR=$got"

065 — System utilities

Required — every box runs this.

Lightweight tools for daily system administration and monitoring.

This runbook adds tools that are useful on a workstation but don't need the full development stack. Everything before this was either about making the box manageable (000-050) or about building the agent harness (060, 070). This is about making the box pleasant to use for routine administration.

Why btop

btop is a terminal-based resource monitor that shows CPU, memory, disk, and network usage in a clean, interactive interface. It's more readable than top or htop, written in C++ with no runtime dependencies beyond libuv and ncurses. The agent harness often needs to watch system resources during long runs, and btop makes that obvious rather than parsing text output.

The package

runs: unzip
sudo pacman -S --needed --noconfirm unzip
verify: unzip
pacman -Qi unzip >/dev/null || { echo "unzip not installed"; exit 1; }
command -v unzip >/dev/null || { echo "unzip not on PATH"; exit 1; }
echo "unzip installed"
runs: btop
sudo pacman -S --needed --noconfirm btop
verify: btop
pacman -Qi btop >/dev/null || { echo "btop not installed"; exit 1; }
command -v btop >/dev/null || { echo "btop not on PATH"; exit 1; }
echo "btop $(btop --version | head -1) installed"

A wrapper for compatibility

Typing btop in a non-tty context (like an agent run with output captured) shows the help rather than failing. Since top is already provided by procps-ng, create a wrapper script at /usr/local/bin/top that invokes btop instead, giving a familiar command name while leaving the original top available for the rare case where someone needs the SysV version.

runs: btop-wrapper
sudo install -d -m 0755 /usr/local/bin
sudo install -m 0755 /dev/stdin /usr/local/bin/top <<'EOF'
#!/usr/bin/env bash
# Managed by /opt/admin/runbooks/065-system-utilities.md
# Invoke btop instead of the sysv top command for a better interface.
exec btop "${@:-}"
EOF
sudo install -m 0755 /dev/stdin /usr/local/bin/htop <<'EOF'
#!/usr/bin/env bash
# Managed by /opt/admin/runbooks/065-system-utilities.md
# Invoke btop instead of htop for consistency.
exec btop "${@:-}"
EOF
command -v top
verify: btop-wrapper
test -x /usr/local/bin/top || { echo "/usr/local/bin/top not executable"; exit 1; }
test -x /usr/local/bin/htop || { echo "/usr/local/bin/htop not executable"; exit 1; }
# Both should invoke btop (compare content since they're scripts, not symlinks)
grep -q 'exec btop' /usr/local/bin/top || { echo "top wrapper doesn't exec btop"; exit 1; }
grep -q 'exec btop' /usr/local/bin/htop || { echo "htop wrapper doesn't exec btop"; exit 1; }
got="$(/usr/local/bin/top --version 2>&1 | head -1 | sed 's/\x1b\[[0-9;]*m//g')"
test "$got" = "btop version: 1.4.7" || { echo "unexpected version output: $got"; exit 1; }
echo "top and htop are btop wrappers"

Next

runbook what it does needs
070-agent-harness.md install the opencode agent harness so the box can maintain itself 060, 065

070 — Agent harness

Optional — a box opts in by adding 070 to OPTIONAL_RUNBOOKS in its <box>.env.

Put a gated LLM agent on the box, so a change can be asked for in prose and arrive as a runbook that ran, rather than as commands nobody wrote down.

Everything before this runbook was driven by an agent harness on a different machine, reaching in over SSH. That works, but it makes the repo something done to this box rather than something the box does. This runbook closes the loop: the machine gets its own harness, this repo's conventions as a skill, and the secrets to use a model — and from then on /adm <describe a desired outcome> is the interface for system maintenance that documents itself as a side effect.

Blocked: this VM's CPU is too old for the agent

opencode is compiled with Bun, which requires AVX2 on x86-64. This VM presents Proxmox's default kvm64 model, which is barely x86-64-v1:

model name : Common KVM processor
avx MISSING   avx2 MISSING   sse4_2 MISSING   popcnt MISSING   bmi2 MISSING

The failure mode is nasty rather than obvious: opencode does not report an illegal instruction, it spins at 100% CPU producing no output and never exits — which reads like a hang or a network problem rather than a missing instruction set.

The fix is a hypervisor-side change, outside what any runbook here can do: set the VM's CPU type to host (or at least x86-64-v3) in Proxmox and reboot. host is the better default for a development box regardless — kvm64 also costs you AES-NI and a decade of other extensions.

The precondition below fails until that is done. That is the runbook reporting something untrue, not a defect.

verify: agent-cpu-support
missing=()
for f in sse4_2 popcnt avx avx2 bmi1 bmi2 fma; do
  grep -qw "$f" /proc/cpuinfo || missing+=("$f")
done
if (( ${#missing[@]} )); then
  echo "vCPU lacks: ${missing[*]}"
  echo "model: $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2- | sed 's/^ //')"
  echo
  echo "opencode is a Bun binary and needs AVX2. On this CPU it spins at 100%"
  echo "forever instead of failing cleanly."
  echo "Fix in the hypervisor: set the VM CPU type to 'host' (or x86-64-v3)"
  echo "and reboot. No runbook can do this from inside the guest."
  exit 1
fi
echo "vCPU supports the instruction sets the agent needs"

The agent

Self-contained binary from Arch's own repositories — depends only on curl, glibc, icu, ripgrep and tar. No AUR, no npm, no install script piped into a shell.

runs: opencode-package
sudo pacman -S --needed --noconfirm opencode
pacman -Q opencode
verify: opencode-package
pacman -Qi opencode >/dev/null || { echo "opencode not installed"; exit 1; }
command -v opencode >/dev/null || { echo "opencode not on PATH"; exit 1; }
echo "$(pacman -Q opencode) installed"

Secrets reach the agent through the environment, never the disk

An API key written into a config file is a plaintext secret on disk forever, and this repo spent 015 avoiding exactly that. sops exec-env decrypts the env file straight into a child process's environment and nowhere else — the mechanism SOPS provides for this, rather than something improvised.

So the agent is launched through adm, never directly. adm agent runs opencode with this box's secrets in its environment and nowhere else, and adm fix is the same command under a second name — whichever reads better at the prompt.

Bare words are a desired outcome, because that is what you almost always want:

adm fix "enable and configure a firewall allowing only ssh"

opens the agent already working on /adm enable and configure .... With no words it opens an empty session. Raw opencode arguments go after --:

adm agent -- models
adm agent -- run --auto 'Reply with exactly: ok'

That split is explicit rather than inferred. Treating a leading opencode subcommand as passthrough would turn adm agent run the backup script into opencode run the backup script and start an agent on a mangled prompt instead of failing — wrong in the shape that looks like it worked.

This was a separate script at /usr/local/bin/adm-agent until it became a subcommand. Two reasons, neither urgent and both small: it now appears in adm --help, which is where someone looks for it, and the launch rules stopped being a generated shell script that had to re-quote its own arguments through three layers of escaping. The rules did not change. They are still the part worth knowing:

sops and opencode are checked when those subcommands run, not when adm starts. adm list and adm run therefore still work on a box where 000 has not installed sops yet — which is not a hypothesis, it is the bootstrap path.

Use adm agent -n or adm fix -n to see the exact command either one would exec, without execing it.

runs: agent-wrapper-removed
# Replay has to take the old wrapper away, not merely stop writing it. It
# lives outside /etc, so facts/ would never report it, and a box carrying an
# undescribed executable that still works is precisely the drift this repo
# exists to prevent.
sudo rm -f /usr/local/bin/adm-agent
command -v adm
verify: agent-wrapper-removed
# An absence assertion that can see what it asserts: confirm the directory is
# readable first, or "not there" cannot be told apart from "not allowed to
# look". Written as `if`, not `test ... && { exit 1; }`, because the latter
# leaves a non-zero status behind on the good path.
sudo test -d /usr/local/bin || { echo "/usr/local/bin is missing"; exit 1; }
if sudo test -e /usr/local/bin/adm-agent; then
  echo "the old adm-agent wrapper is still installed; replay 070"
  exit 1
fi
# Prove the injection mechanism without involving the agent: a value from the
# encrypted env must reach the child process.
got="$(sops exec-env "$ADM_BOX_DIR/$BOX_NAME.env" 'printenv BOX_NAME')"
test "$got" = "$BOX_NAME" || { echo "sops exec-env did not inject BOX_NAME"; exit 1; }
# And adm must actually be able to build the launch command.
cmd="$(adm agent -n)" || { echo "adm agent -n failed"; exit 1; }
grep -q -- "--standalone" <<<"$cmd" \
  || { echo "adm agent dropped --standalone; the key would not reach opencode"; exit 1; }
echo "wrapper removed; adm agent builds its command and sops injects for the child"

The key itself

Add it to the encrypted env file by hand — it is a credential, so no runbook generates it:

sops rea-dev/rea-dev.env        # $EDITOR opens it decrypted, saves encrypted

Add the variable your provider expects (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) and a non-secret LLM_MODEL. It is encrypted because it is not in .sops.yaml's unencrypted_regex, which is the correct default for anything new.

Lock the agent to one provider

This is the most important block in the runbook, and it exists because of something found the hard way. With no configuration and no credentials at all, opencode answered a prompt successfully — which looked like a win and was not. It had silently routed inference through its own hosted gateway:

outbound during a run:  104.20.32.17:443  -> api.opencode.ai / gateway.opencode.ai
                        172.65.90.21,.23  -> opencode.ai
opencode models:        10 models, every one opencode/*-free

Nothing asked for that. An agent working in /opt/admin reads this repo, so on a default install this box sends prompts — and the contents of whatever files the agent opens — to a third party that was never chosen. The facts/ snapshots alone describe the network layout.

So the configuration is default-deny, not merely "set a preferred model". Pinning a model while leaving other providers reachable is a preference; denying every provider but one is a control.

The four outbound paths, all closed

opencode's schema turns out to document each of these, and all four are set explicitly rather than left at their defaults:

key value why
policies deny *, then allow openai Access control on provider.use. The deny-all is what makes the hosted gateway unreachable rather than merely unpreferred. Order is last-match-wins — the opposite of firewall convention. Written allow-first, as I did at first, it denies everything including the provider you just allowed, and opencode models silently returns nothing
share disabled Sessions can otherwise be uploaded to opencode.ai for sharing. A session transcript of work in this repo is exactly what must not leave
websearch false A search provider is another egress path, and another party receiving prompt text
update disable No update checks. pacman owns this package; a self-updating binary would also drift out from under the runbook

Note the schema marks enabled_providers, disabled_providers, provider and autoupdate as deprecated — policies, providers and update are the current spellings. Building on the deprecated keys would have worked today and broken quietly later.

Which built-in provider to override, and why it is not openai

This took several attempts; the failures are recorded because none of them is hinted at by the schema.

A custom provider with package: "@ai-sdk/openai-compatible" parses cleanly — opencode debug config shows it loaded, provider.updated fires, no error appears at any log level — and then offers zero models. The same shape with canonical: "openai" under a custom id behaves identically. Both fail silently, which is the worst way to fail. Only overriding a provider opencode already knows works.

The obvious choice, openai, works for exactly one turn and then breaks:

Error: 158 validation errors
  'loc': ('body', 'input', ...), 'msg': "Input should be 'input_text'",
  'input': 'output_text'

That is vLLM rejecting a request to /v1/responses. The giveaways are EasyInputMessageParam and ResponseInputTextParam, and body.input — the Responses API takes input, chat completions takes messages. On the second turn opencode echoes the previous assistant message back as output_text where vLLM's schema demands input_text, and the request dies. The first turn has no assistant message to echo, so it succeeds — which is why this looked like a working harness.

The provider's npm package decides the wire protocol, and models.dev records it per provider:

provider npm protocol
openai @ai-sdk/openai Responses API — incompatible here
lmstudio @ai-sdk/openai-compatible chat completions — works
deepseek, llama, ollama-cloud @ai-sdk/openai-compatible also fine

So the override targets lmstudio, whose catalogue entry is literally a self-hosted OpenAI-compatible server on a local URL — which is exactly what vLLM is. Selecting it by protocol rather than by name is the point; the name is incidental.

You are not running LM Studio. The id is a protocol selector, nothing more: opencode has no vllm provider, and the ~190 providers sharing @ai-sdk/openai-compatible are all named for commercial services — there is no neutral "self-hosted" id to borrow. lmstudio is simply the one whose catalogue entry describes a local OpenAI-compatible server, which is what vLLM is.

Renaming it is not possible. A custom id with package set offers no models at all; a custom id with canonical: "lmstudio" aliases to LM Studio entirely — it returns LM Studio's own catalogue (gpt-oss-20b, qwen3-30b-a3b) under an lmstudio/ prefix and ignores both our baseURL and our models. So the borrowed id leaks into the model reference (lmstudio/<model>) and there is no way around it.

What is adjustable is the display name, which the block below sets to name vLLM explicitly — so anywhere opencode shows the provider, it tells the truth about what it is talking to.

Check the npm field before choosing a different one:

curl -s https://models.dev/api.json | jq -r '.<provider> | {npm, api, env}'

Configuration comes from <box>.env

key required what
LLM_BASE_URL yes the OpenAI-compatible endpoint, including /v1
LLM_MODEL yes the model id the endpoint serves, as /v1/models reports it
LLM_API_KEY yes mapped to LMSTUDIO_API_KEY by adm agent; any value if the endpoint ignores keys

The key is never written into the config file — adm agent injects it with sops exec-env for one process. A key in opencode.json would be a plaintext secret on disk, which is what 015 exists to prevent.

One confirmation, at the only channel that changes the box

Out of the box the agent asks for nothing. opencode debug agents reports the default build agent resolving to a single rule — * * -> allow — so every command runs the moment it is chosen. That is upstream's documented default rather than a misconfiguration, but it makes a session impossible to follow: the box is already changed by the time you have read why.

The obvious fix is to gate the dangerous verbs — sudo, pacman, systemctl — and it is the wrong one. It prompts several times per task, each prompt carrying too little context to judge on its own, and an operator who is asked twelve times learns to approve without reading. A gate that is always answered yes is decoration.

This repo makes a better gate available. Nothing changes this box except through adm run — see AGENTS.md — so that one command is the entire boundary between investigating and mutating. Gating it yields exactly one approval per runbook execution, at the moment the plan is known and before anything has happened, and the whole runbook runs under that single yes however many blocks it contains.

So investigation is deliberately ungated and unlimited: reading files, grep, systemctl status, adm list, adm verify, adm snapshots. sudo and git push are gated as well, not as routine friction but as a tripwire — an agent reaching for sudo directly is bypassing the runbook rule, and that is worth an interruption rather than a note in the transcript afterwards.

adm undo and adm prune are gated for the obvious reason, and adm agent and adm fix for a less obvious one: adm agent -- run --auto '...' starts a headless agent, and headless does not consult this ruleset at all. Left ungated, the gate could be stepped around by an agent simply launching another one. Gating the launcher closes that, and the cost is a prompt on a command nobody runs from inside a session anyway.

Which raises the maintenance problem this list has: every new mutating subcommand needs a rule, and forgetting is silent. The agent-gate block below therefore reads adm's own subcommand list and asserts that each one is either gated or on an explicit read-only allowlist, so a subcommand added without a rule fails the health check instead of being discovered in use.

Note what this does not rely on: plan mode. The built-in plan agent denies edit and leaves bash alone. On a normal codebase that is read-only, because changes arrive as file writes; here it is nearly meaningless, since sudo pacman -S, systemctl and adm run all still execute in it — on this box mutation travels through bash. The gate above holds in any agent, which is why it is configured globally rather than per-agent.

What the gate does not cover, found by testing it rather than by reading. The ruleset is consulted by the interactive TUI. It is not consulted by opencode run. With bash * -> deny resolved — confirmed in opencode debug agents — a headless run still executed sudo -n true and reported its exit code. That is not a pattern mismatch: a blanket deny behaves exactly like a narrow one, so headless runs do not consult the ruleset at all.

So the gate protects the operator's interactive session, which is how this box is actually driven — see "Running it" below — and protects nothing whatsoever in adm agent -- run. Treat a headless invocation as unrestricted, and do not put one anywhere that a wrong command would matter.

That the TUI does stop and ask is the one claim here that no verify block can make: it needs a terminal and a human to answer the prompt. It was confirmed by hand on 2026-10-19, which is the only way it could be — /adm install the unzip package in an interactive session prompted for approval before anything had changed, and the change arrived as a runbook block with its own verify rather than as a bare pacman call (commits fedeb45, fdab3aa).

So the prompt is real. It is simply not a property adm verify can re-prove, which makes this paragraph the evidence rather than the block below. Re-check it by hand after any opencode upgrade: permission handling is exactly the kind of behaviour a minor version can change silently, and nothing here would notice.

The three read rules look redundant — opencode already asks before reading a .env by default — and they are not. Rules merge with the built-in ruleset appended to, and the catch-all * * -> allow above sits after the defaults, so it silently re-permits the very .env reads the defaults protect. They have to be restated below it to survive, *.env.example included, or sops-encrypted configuration becomes readable without a prompt by accident. This is the cost of last-match-wins: a catch-all is never only a catch-all.

The behavioural half — present a plan, ask once, then finish the job without further check-ins — lives in skills/adm.md, written further down this runbook. The permissions here are what make it more than a polite request.

runs: opencode-config
install -d -m 0755 "$HOME/.config/opencode"
: "${LLM_BASE_URL:?not set in $BOX_NAME.env -- refusing to leave the endpoint unpinned}"
: "${LLM_MODEL:?not set in $BOX_NAME.env -- refusing to leave the model unpinned}"
# Asserted even though some endpoints ignore it, because "no key needed" and
# "nobody set a key" are the same absence from here, and `adm agent` exports
# whatever it finds -- so an endpoint that does want one receives an empty
# string and answers 401 from somewhere much further away. Set it to any
# non-empty value if your endpoint does not check keys; that records the
# decision instead of leaving it to be inferred from a missing line.
#
# Being asserted is also what makes it visible: `adm adopt` and `adm env-fill`
# derive the keys a box needs from exactly these ${KEY:?} forms, and this one
# and FORGE_TOKEN were the two they could not see -- the two most secret
# values in the repo, which is to say the two env-fill exists for.
: "${LLM_API_KEY:?not set in $BOX_NAME.env -- set it to any value if this endpoint ignores keys, and add it with: adm env-fill}"

install -m 0644 /dev/stdin "$HOME/.config/opencode/opencode.json" <<EOF
{
  "\$schema": "https://opencode.ai/config.json",
  "providers": {
    "lmstudio": {
      "name": "vLLM self-hosted - id 'lmstudio' borrowed for its chat-completions protocol",
      "settings": { "baseURL": "$LLM_BASE_URL" },
      "models": { "$LLM_MODEL": { "name": "$LLM_MODEL" } }
    }
  },
  "policies": [
    { "action": "provider.use", "resource": "*", "effect": "deny" },
    { "action": "provider.use", "resource": "lmstudio", "effect": "allow" }
  ],
  "permissions": [
    { "action": "*",    "resource": "*",            "effect": "allow" },
    { "action": "bash", "resource": "adm run *",    "effect": "ask" },
    { "action": "bash", "resource": "adm replay *", "effect": "ask" },
    { "action": "bash", "resource": "adm undo *",   "effect": "ask" },
    { "action": "bash", "resource": "adm restore *","effect": "ask" },
    { "action": "bash", "resource": "adm prune *",  "effect": "ask" },
    { "action": "bash", "resource": "adm publish *","effect": "ask" },
    { "action": "bash", "resource": "adm upstream *","effect": "ask" },
    { "action": "bash", "resource": "adm adopt *",  "effect": "ask" },
    { "action": "bash", "resource": "adm enable *", "effect": "ask" },
    { "action": "bash", "resource": "adm disable *","effect": "ask" },
    { "action": "bash", "resource": "adm land *",   "effect": "ask" },
    { "action": "bash", "resource": "adm env-fill *","effect": "ask" },
    { "action": "bash", "resource": "adm unlock *", "effect": "ask" },
    { "action": "bash", "resource": "adm issue *",  "effect": "ask" },
    { "action": "bash", "resource": "adm reencrypt *","effect": "ask" },
    { "action": "bash", "resource": "adm agent *",  "effect": "ask" },
    { "action": "bash", "resource": "adm fix *",    "effect": "ask" },
    { "action": "bash", "resource": "sudo *",       "effect": "ask" },
    { "action": "bash", "resource": "git push *",   "effect": "ask" },
    { "action": "read", "resource": "*.env",        "effect": "ask" },
    { "action": "read", "resource": "*.env.*",      "effect": "ask" },
    { "action": "read", "resource": "*.env.example","effect": "allow" }
  ],
  "model": "lmstudio/$LLM_MODEL",
  "share": "disabled",
  "websearch": false,
  "update": "disable"
}
EOF
jq -c '{model, baseURL: .providers.lmstudio.settings.baseURL,
         policies: (.policies|length),
         gated: [.permissions[] | select(.effect=="ask") | .resource]}' \
  "$HOME/.config/opencode/opencode.json"
verify: opencode-config
f="$HOME/.config/opencode/opencode.json"
test -f "$f" || { echo "$f missing -- the endpoint is unpinned, see this runbook"; exit 1; }
jq -e . "$f" >/dev/null || { echo "$f is not valid JSON"; exit 1; }

test "$(jq -r '.model // empty' "$f")" = "lmstudio/$LLM_MODEL" \
  || { echo "pinned model is '$(jq -r .model "$f")', expected lmstudio/$LLM_MODEL"; exit 1; }
# Guard the protocol choice: @ai-sdk/openai speaks the Responses API, which
# vLLM rejects on the second turn. See this runbook.
jq -e '.providers | has("openai") | not' "$f" >/dev/null \
  || { echo "provider 'openai' is configured; it uses the Responses API and breaks multi-turn"; exit 1; }
# The base URL is the actual egress control: every request goes here.
test "$(jq -r '.providers.lmstudio.settings.baseURL // empty' "$f")" = "$LLM_BASE_URL" \
  || { echo "baseURL does not match LLM_BASE_URL"; exit 1; }

# Order matters and is counter-intuitive: LAST match wins, so the deny-all
# must come FIRST. Reversed, it denies everything including openai.
jq -e '[.policies[] | select(.action=="provider.use") | {r: .resource, e: .effect}]
       == [{r:"*", e:"deny"}, {r:"lmstudio", e:"allow"}]' "$f" >/dev/null \
  || { echo "provider.use policies must be exactly [deny *, allow lmstudio], in that order:"
       jq -c '[.policies[] | select(.action=="provider.use")]' "$f"; exit 1; }

test "$(jq -r '.share' "$f")"     = "disabled" || { echo "share is not disabled"; exit 1; }
test "$(jq -r '.update' "$f")"    = "disable"  || { echo "update checks not disabled"; exit 1; }
test "$(jq -r '.websearch' "$f")" = "false"    || { echo "websearch is not disabled"; exit 1; }

# No credential may be written into the config.
grep -qE '"(api[Kk]ey|apikey)"[[:space:]]*:' "$f" \
  && { echo "$f contains an apiKey field -- the key belongs in the env, not here"; exit 1; }

# The gate. Exactly these four, so a fifth added by hand shows up here as a
# decision nobody wrote down rather than as silent extra friction.
jq -e '[.permissions[] | select(.action=="bash") | "\(.resource) \(.effect)"]
       == ["adm run * ask", "adm replay * ask", "adm undo * ask",
           "adm restore * ask", "adm prune * ask", "adm publish * ask",
           "adm upstream * ask", "adm adopt * ask", "adm enable * ask",
           "adm disable * ask", "adm land * ask", "adm env-fill * ask",
           "adm unlock * ask", "adm issue * ask",
           "adm reencrypt * ask",
           "adm agent * ask", "adm fix * ask",
           "sudo * ask", "git push * ask"]' "$f" >/dev/null \
  || { echo "the bash gate list is not the expected set, in order:"
       jq -c '[.permissions[] | select(.action=="bash")]' "$f"; exit 1; }
# Catch-all first: last match wins, same counter-intuitive order as policies.
# Reversed, every read-only command would prompt and the gate becomes noise.
test "$(jq -r '.permissions[0] | "\(.action) \(.resource) \(.effect)"' "$f")" = "* * allow" \
  || { echo "permissions[0] must be the catch-all allow; got: $(jq -c '.permissions[0]' "$f")"; exit 1; }

# Every mutating adm subcommand must have a gate. The list comes from adm
# itself, so one added without a rule fails here rather than being discovered
# in use. Asserted against the file, not the resolved ruleset: opencode's
# server caches the config, so resolution lags a rewrite and is the wrong place
# to check a list that changes whenever adm grows a subcommand.
#
# The allowlist is the judgement and is spelled out deliberately.
# list/verify/log/snapshots only read. facts writes, but only its own directory
# plus a commit of it, and it is meant to run at the end of every session, so
# gating it would train the operator to approve without reading -- the habit
# this gate exists to prevent. checkpoint is allowed for a different reason: it
# is a *safety* action, and requiring permission to be careful is backwards.
# The cost is that checkpoints never expire, so an agent could accumulate them;
# that is slow, visible in `adm snapshots`, and the operator's to prune.
# export writes one generated file under export/ and refuses to write at all if
# the result would contain a decrypted value, so the dangerous half of
# publishing a document is not here -- it is in sending it somewhere, which is
# `adm publish`, and that is gated -- it moves this machine's description off
# the machine. `adm issue` is gated for the same reason in miniature: the body
# is text this box composes and sends somewhere other people read.
readonly_ok="list verify log snapshots facts checkpoint export env-check"
# [a-z,-] rather than [a-z,]: a hyphenated subcommand (env-check) made the
# narrower pattern match nothing at all. It failed loudly thanks to the
# emptiness guard below, which is the only reason it was not a silent pass.
subs="$(adm --help 2>&1 | grep -o '{[a-z,-]*}' | head -1 | tr -d '{}' | tr ',' ' ')"
test -n "$subs" || { echo "could not read adm's subcommand list from --help"; exit 1; }
gated="$(jq -r '[.permissions[] | select(.action=="bash" and .effect=="ask")
                | .resource] | join(" ")' "$f")"
for s in $subs; do
  case " $readonly_ok " in *" $s "*) continue ;; esac
  case " $gated " in *" adm $s * "*) continue ;; esac
  echo "adm subcommand '$s' is not gated and is not on the read-only allowlist"
  echo "either add a rule in this runbook or justify it in readonly_ok"
  exit 1
done

echo "pinned to lmstudio/$LLM_MODEL at $LLM_BASE_URL; share/websearch/update off; no key in file"
echo "gated ($(wc -w <<<"$subs") subcommands checked): $(jq -r '[.permissions[]
  | select(.effect=="ask" and .action=="bash") | .resource] | join(", ")' "$f")"

Prove the stock endpoints are gone

Before the lockdown, opencode models listed ten opencode/*-free models served by the hosted gateway. The assertion is that no provider other than the one we configured is reachable — not that the model list is short, since the openai catalogue comes along for the ride.

verify: no-stock-providers
test -f "$HOME/.config/opencode/opencode.json" \
  || { echo "no config yet; see opencode-config"; exit 1; }
models="$(timeout 120 adm agent -- models 2>&1 </dev/null || true)"
test -n "$models" || { echo "adm agent -- models produced nothing in 120s"; exit 1; }

# Any provider prefix other than openai/ means something else is reachable.
foreign="$(grep -E '^[a-z0-9_-]+/' <<<"$models" | grep -v '^lmstudio/' || true)"
if [[ -n "$foreign" ]]; then
  echo "providers other than the configured one are reachable:"
  printf '%s\n' "$foreign" | head -10 | sed 's/^/  /'
  exit 1
fi
grep -qx "lmstudio/$LLM_MODEL" <<<"$models" \
  || { echo "configured model lmstudio/$LLM_MODEL is not offered"; exit 1; }
echo "only the configured provider is reachable; lmstudio/$LLM_MODEL present"

Prove the gate resolved, not merely that it was written

The config key for this was renamed: permission — singular, an object of objects — is deprecated in favour of permissions, a plural ordered array of {action, resource, effect}. Upstream's own permissions page still documents the deprecated form, so the wrong one is the easy one to write. Writing it is not an error. It is silently ignored, which would leave a config file that reads as gated and an agent that asks for nothing — the failure this repo is least able to detect by reading itself.

So this asserts what the binary resolved, through opencode debug agents, rather than what the file contains. The file having a key is paperwork; the agent having a rule is the property.

Resolution is as far as a verify block can reach. It proves the key was not silently ignored. It does not prove anything stops and asks — and, per the section above, in a headless run nothing does. Hence the wording of the message it prints: the block is deliberately not allowed to sound like proof of enforcement.

verify: agent-gate
command -v opencode >/dev/null || { echo "opencode not installed"; exit 1; }
cd "$ADM_ROOT"
# `opencode debug agents` spawns a server to answer, so it can return nothing
# while another opencode is mid-start. That happens routinely right here:
# no-stock-providers and agent-smoke each start one just before this block, so
# a single attempt reported a policy failure that was not one. Retry instead,
# and keep stderr -- discarding it was why the first failure explained nothing.
out="$(mktemp)"; err="$(mktemp)"
got=""
for _ in 1 2 3; do
  timeout 30 opencode debug agents >"$out" 2>"$err" || true
  got="$(jq -r '.[] | select(.id=="build") | .permissions[] | "\(.action) \(.resource) \(.effect)"' \
         "$out" 2>/dev/null || true)"
  [[ -n "$got" ]] && break
  sleep 2
done
if [[ -z "$got" ]]; then
  echo "opencode debug agents reported no permissions for 'build' after 3 tries"
  echo "stderr:"; sed 's/^/  /' "$err" | head -5
  rm -f "$out" "$err"
  exit 1
fi
rm -f "$out" "$err"

for want in "bash adm run * ask" "bash adm replay * ask" "bash sudo * ask" "bash git push * ask"; do
  grep -qxF "$want" <<<"$got" \
    || { echo "the 'build' agent did not resolve the gate: $want"
         echo "resolved ruleset was:"; printf '%s\n' "$got" | sed 's/^/  /'
         echo "a deprecated 'permission' key is ignored in silence -- see this runbook"
         exit 1; }
done
# Investigation must stay ungated, or the operator drowns in prompts and the
# single meaningful confirmation stops being read.
grep -qxF "* * allow" <<<"$got" \
  || { echo "no catch-all allow resolved; read-only work would prompt"; exit 1; }
# The catch-all must not have re-permitted .env reads. Order decides this, so
# assert the resolved tail rather than the mere presence of a rule.
test "$(grep -n 'read \*\.env ask' <<<"$got" | tail -1 | cut -d: -f1)" \
   -gt "$(grep -n '^\* \* allow$' <<<"$got" | tail -1 | cut -d: -f1)" \
  || { echo "the catch-all allow outranks the .env guard; encrypted config is readable unprompted"
       printf '%s\n' "$got" | sed 's/^/  /'; exit 1; }
# Only rules that do not change are asserted here. The list of gates lives in
# the file and is checked there, because opencode's background server caches
# the config: seconds after opencode-config rewrites it, `debug agents` can
# still report the previous ruleset. That would make this block flaky in the
# worse direction -- a false pass on a stale read -- and it already caught
# itself doing exactly that when `adm restore` was added.
echo "gate resolves: catch-all allow, adm run/replay, sudo, git push, .env guard"
echo "note: resolution only -- the TUI enforces this, 'opencode run' does not"

adm fix must not take the headless path

adm fix exists to save typing, and the cheap way to build it would have been the non-interactive one: no terminal required, scriptable, obvious. That implementation would be silently wrong. Headless runs do not consult the permission ruleset at all, so a fix routed through opencode run would discard the approval gate above while looking identical from the outside — the worst shape of regression, because nothing fails.

So it opens the TUI with --prompt, which submits the slash command into an interactive session and leaves the gate in place. The block below asserts the command adm fix would actually exec, which is what -n is for; grepping bin/adm would only establish that the right words appear somewhere in the source.

verify: agent-fix-gate
cmd="$(adm fix -n 'enable a firewall' 2>&1)" \
  || { echo "adm fix -n failed: $cmd"; exit 1; }
grep -q -- "--prompt" <<<"$cmd" \
  || { echo "adm fix does not pass --prompt:"; echo "  $cmd"; exit 1; }
grep -qF -- "/adm enable a firewall" <<<"$cmd" \
  || { echo "adm fix did not build the slash command:"; echo "  $cmd"; exit 1; }
grep -q -- "--standalone" <<<"$cmd" \
  || { echo "adm fix lost --standalone:"; echo "  $cmd"; exit 1; }
# The regression this block exists for: the headless subcommand in place of
# the interactive session. Written as `if`, so the good path leaves a zero.
if grep -qE "exec opencode run( |$)" <<<"$cmd"; then
  echo "adm fix is routed through 'opencode run', which ignores permissions:"
  echo "  $cmd"
  exit 1
fi
# An alias, not a similar command: identical output for identical input.
a="$(adm agent -n 'enable a firewall')"
b="$(adm fix -n 'enable a firewall')"
test "$a" = "$b" \
  || { echo "adm fix and adm agent disagree:"; echo "  $a"; echo "  $b"; exit 1; }

# And words after -- are opencode's arguments, not an outcome. If this ever
# starts producing a prompt, `adm agent -- models` is quietly asking an agent
# to think about the word "models" instead of listing them.
raw="$(adm agent -n -- models)"
if grep -q -- "--prompt" <<<"$raw"; then
  echo "raw passthrough after -- was turned into a prompt:"; echo "  $raw"; exit 1
fi
grep -qE "exec opencode models( |$)" <<<"$raw" \
  || { echo "adm agent -- models did not pass the subcommand through:"
       echo "  $raw"; exit 1; }
echo "adm fix == adm agent; outcomes use the TUI, -- passes opencode args through"

This repo's conventions, as a skill

The canonical text lives in skills/adm.md, written by this runbook, and is installed into both agents' conventions so the same instructions apply whether the work happens on the box or from a workstation:

Two materialised copies from one source, for the same reason the git hooks work that way: a skill that exists only in one agent's format is a skill the other agent silently ignores.

runs: agent-skill
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands .claude/skills/adm
install -m 0644 /dev/stdin skills/adm.md <<'SKILL'
Maintain this Arch box by changing the documentation, not the machine.

The user will describe a desired outcome. Deliver it by adding or amending a
runbook in this repository and running it — never by configuring the box
directly. A change made at a bare shell is invisible to this repo, and the
repo's entire value is that reading it tells you what the box is.

## Before anything

Read `AGENTS.md` and `README.md`. They are the contract, they are short, and
they contain rules that are not guessable — the fence syntax is local to this
repo, idempotence is mandatory and unchecked, and verify blocks have
non-obvious failure modes.

Investigate read-only first, and do it properly. Reading is ungated and
unlimited — files, `grep`, `systemctl status`, `journalctl`, `adm list`,
`adm verify` — so there is never a reason to guess at something you could
check, and no need to ask permission to look.

## Present a plan, then wait

The box changes only through `adm run`, and that command stops and asks. So a
task has exactly one moment that needs the operator, and it falls before any
of the work: the handoff from investigating to executing.

Before writing to a runbook or running one, show:

- **The outcome**, restated in your own words, so a misunderstanding surfaces
  here instead of afterwards.
- **Which runbook** — amended or new — and for a new one, its number and why
  it belongs there.
- **Each block** you intend to add, by `id`, one line on what it does.
- **What the verify block will assert** — the observable property, not the
  fact that you ran the command.
- **What this touches** outside the repo: packages, units, files, services.
- **Anything you are unsure about**, named as such rather than smoothed over.

Then ask for a go-ahead and stop. Do not begin writing the runbook while
waiting for the answer.

## Ask once, then finish the job

Once the plan is approved, carry it through to the end without further
check-ins: write the runbook, run it, iterate on failures, verify, snapshot,
and land it.

The approval covers the landing. `adm land` files the issue, opens the pull
request and merges it in one gated command precisely so finishing does not
cost three more prompts -- raising them after the single approval is the
fatigue the gate exists to prevent.

## Secrets are not yours to carry

When a runbook needs a value you do not have -- a git remote, a host
fingerprint, an API key -- **stop and ask the operator to type it in
themselves**:

    ssh -t <box> 'cd /opt/admin && adm env-fill'

Do not ask them to paste it to you, and do not offer to set it for them.
There is no flag that would let you: a value passed on a command line is in
your context, in your transcript, in shell history and in `/proc` for anyone
on the box. `adm env-fill` refuses to run without a terminal for exactly that
reason, and `getpass` reads the terminal directly, so a secret cannot be
piped in by anything.

What you may do is say *which* keys are missing and which runbook wants each
-- `adm env-check` and `adm adopt` both report that, and the names are not
secret. Then wait. When they say it is done, `adm env-check` will tell you
whether it is.

This is the one place the single approval does not carry you through. The
work stops until a person has typed something, and that is deliberate.

## Landing the work

Work on a branch and land it when it verifies:

    git checkout -b <short-name>
    ...edit, adm run, iterate...
    adm land -m "<what the operator asked for, in their terms>"

`adm land` verifies **before** publishing anything and merges only if that is
green. The pull request carries the verify output as evidence, the issue
records the request, and the commit messages carry the reasoning -- three
different questions, which is why there are three artifacts.

The `-m` text is the operator's request, not a summary of what you did. The
commits already say what you did.

### A commit message here is published

`adm upstream` copies these messages into the public repo as the pull
request's title and body, where there is no issue to click through to and no
access to this repository. So each message has to stand on its own.

State the problem first, in a sentence or two that assume nothing: what went
wrong, for whom, and how it showed. Then the implementation, briefly, and
only the parts a reader would otherwise wonder about. A message that opens
with "it was set globally" is a defence of a fix for something the reader has
not been told about -- correct and useless. That is a real example, not an
invented one: a message was rewritten for exactly this reason.

Write it for someone reading the public history in a year, who was not here.

If verify is red nothing is published, and the branch is the only record of
why the box changed. Fix it and land again, or revert the box with `adm undo`.
`adm land` will not undo for you: reverting a machine is the operator's
decision.

Something unrelated that you notice along the way is not a reason to stop.
Keep a note of it, finish what was approved, and file it with `/issue` before
you report back — not when you spot it, since `adm issue` is gated and that
would be an unplanned prompt in the middle of approved work.

Narrate as you go — which block is running, what came back — so the work can
be followed while it happens. Narrating is not asking. Stopping to confirm
each command is the failure this protocol exists to prevent: it teaches the
operator to approve without reading, which is worse than never asking.

Come back for a second confirmation only when:

- the plan was wrong, and the real fix is materially different from the one
  approved;
- the work needs something no runbook can reach — a hypervisor change, a
  forge credential, a secret;
- or a verify block fails in a way you cannot fix without widening the scope.

In those cases, say plainly what changed and re-present the plan briefly.

## Then, once the plan is approved

1. **Pick the runbook.** Amend an existing one if the change belongs to its
   topic; otherwise create `runbooks/NNN-topic.md`. Filename order is
   execution order, so a runbook may depend only on what is numbered below
   it. Step by 10.
2. **Write why it exists before writing what it does.** The constraint, the
   decision, the thing that will be non-obvious in six months, what you tried
   that did not work. This is the part no other tool preserves, and the reason
   this repo is prose rather than a script.

   Write about *this box*. Not what the software is, not how it compares to
   the alternatives — a reader can look that up, and it dates badly.

   If there is no non-obvious reason, say so in one line and move on. A
   package installed because something needed it needs no essay. Manufacturing
   rationale to fill the section is worse than a short runbook: it reads as a
   decision that was made when none was, and the next person has to work out
   that there was nothing to understand.
3. **Write the `bash` block.** Idempotent, explicit `sudo` on privileged
   commands, whole files from heredocs rather than appends. Per-box values
   come from `<box>.env` via the environment — never hardcode a hostname, a
   user, or a URL.
4. **Write a `verify` block** that asserts the property you actually care
   about, read-only and fast, and that can *see* what it asserts — an
   unprivileged check against a root-owned path can succeed for the wrong
   reason. It must also be unable to fail for reasons unrelated to that
   property: empty output from a probe means "could not observe", not
   "absent", and a block that cries wolf gets ignored precisely when it
   matters.
5. **Run it** with `adm run <runbook>` — this is where the single approval
   prompt appears — and iterate until green. If a verify fails, prefer fixing
   the runbook over loosening the check.
6. **Record state and commit:** `adm verify && adm facts`, then commit with a
   message explaining *why*, and push.

## Rolling back

`adm run` brackets every invocation in a btrfs snapshot pair, so undoing one
runbook is `adm undo <pre>..<post>`; `adm snapshots` lists them and
`adm prune --empty` clears the pairs whose invocation changed nothing. Two
kinds of rollback request arrive, and they are not the same request.

**Undoing something from this session is routine.** You took the snapshot, you
know what is in the pair, and the runbook that caused it is in front of you. Do
it — and then deal with the runbook too: revert the edit, or say in its prose
why the change was abandoned. A reverted box sitting beside a runbook that
still describes the change is this repo turning into fiction, which is the one
failure it cannot absorb.

**Undoing something from outside this conversation needs care.** You cannot
know what happened in between: another session, another operator, an upgrade,
unrelated drift. So before proposing it, show what the pair contains, say
plainly what you cannot account for, and get a decision. Prefer the narrowest
undo that fixes the problem over reverting a whole pair. The fact that you can
name a snapshot number is not evidence that you understand what reverting it
will do.

**Verify after every rollback.** `adm undo` runs the verify pass itself and
will not pretend a failure is fine. Failures there are *expected*: a rollback
reverts the box behind its own documentation, and each failure names a runbook
that now describes something untrue. Resolve them by re-running that runbook or
by amending it. Never leave them failing and never weaken the check.

**Do not offer what does not exist.** `snapper rollback` cannot work here: the
root filesystem is the top-level subvolume, so there is no default subvolume to
swap for a snapshot. There is no "boot into the previous state" on this box,
only file-level undo. Snapper does not promise consistency afterwards either —
its own manual's example is undoing a user creation and leaving that user's
files behind. Package installs are the sharp case, since the files and the
pacman database can end up disagreeing with each other.

## When something cannot be done

Say so in the runbook, and let the verify block fail honestly. A check that
reports an untrue thing as fine is worse than no check. Hypervisor actions,
forge actions and anything needing a credential are legitimately outside a
runbook's reach — document the manual step rather than faking it.

## Do not

- Mutate the system outside a runbook.
- Revert the box without verifying afterwards, or treat the resulting failures
  as noise.
- Begin the work before the plan has been approved.
- Ask for approval command by command. One plan, one confirmation.
- Weaken a verify block to make a run pass.
- Commit a secret. Values in `<box>.env` are SOPS-encrypted and the git hooks
  will refuse a plaintext one; do not work around them.
- Leave a runbook describing a thing you did not actually run.
SKILL

# opencode: a slash command, with the user's request appended.
#
# Installed under BOTH command/ and commands/. v2's documentation specifies
# the plural directory; the singular one is what this repo wrote first and
# what earlier versions read. Which one this build honours could not be
# settled from outside: /api/command reports an empty list even for a command
# defined in opencode.json, so it is not a usable probe. An unregistered
# command fails as a slash command that merely does not exist, with nothing
# logged anywhere -- so install both.
#
# "Stop guessing" is what this used to say, and the guessing has since been
# done properly without settling it. opencode's current documentation names
# the plural -- .opencode/commands/ and ~/.config/opencode/commands/ -- but
# the version pinned above is v2.0.32, and which directory *it* reads cannot
# be established by inspection: the binary is 219 MB of bundled JavaScript,
# ".opencode" appears only as a bare literal joined at runtime, and neither
# "command" nor "commands" appears as a path constant anywhere near code that
# builds a path.
#
# So both stay, and the cost is three duplicated files. Removing the one that
# turns out to be live costs a slash command that silently does not exist,
# which is the failure this whole comment is about. Settling it needs a live
# test rather than a reading: delete one directory, start opencode, and type
# /adm. Worth doing before 1.0; not worth guessing from documentation written
# for a later version than the one installed.
cmd="$(mktemp)"
{
  printf -- '---\ndescription: Maintain this Arch box by changing its runbooks, self-documenting the result\n---\n\n'
  cat skills/adm.md
  printf '\n## Requested outcome\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/adm.md
install -m 0644 "$cmd" .opencode/commands/adm.md
rm -f "$cmd"

# Claude Code: a project skill
{
  printf -- '---\nname: adm\ndescription: Maintain this Arch box by adding or amending a runbook in /opt/admin and running it, so the change documents itself. Use for any system maintenance, package, service or configuration task on this machine.\n---\n\n'
  cat skills/adm.md
} > .claude/skills/adm/SKILL.md
chmod 0644 .claude/skills/adm/SKILL.md

wc -l skills/adm.md .opencode/command/adm.md .opencode/commands/adm.md \
  .claude/skills/adm/SKILL.md
verify: agent-skill
cd "$ADM_ROOT"
copies=(.opencode/command/adm.md .opencode/commands/adm.md .claude/skills/adm/SKILL.md)
for f in skills/adm.md "${copies[@]}"; do
  test -s "$f" || { echo "$f missing or empty"; exit 1; }
done
for f in .opencode/command/adm.md .opencode/commands/adm.md; do
  head -1 "$f" | grep -qx -- '---' || { echo "$f lacks frontmatter"; exit 1; }
  grep -q '\$ARGUMENTS' "$f" || { echo "$f does not take the user's request"; exit 1; }
done
grep -q '^name: adm$' .claude/skills/adm/SKILL.md \
  || { echo "claude skill lacks a name"; exit 1; }
# Every installed copy must still match the canonical source -- including the
# plan protocol, which is the point of the skill and the easiest thing to lose
# by hand-editing one copy.
for f in "${copies[@]}"; do
  for needle in 'Mutate the system outside a runbook' \
                'Present a plan, then wait' \
                'Ask once, then finish the job' \
                'Rolling back'; do
    grep -qF "$needle" "$f" \
      || { echo "$f is stale relative to skills/adm.md (missing: $needle)"; exit 1; }
  done
done
echo "skill installed for opencode (command/ and commands/) and Claude Code"

/issue, for the thing you noticed but should not fix now

An agent in the middle of one task regularly notices another: a stale reference, a check that cannot fail, a runbook that would break on a rebuild. Fixing it immediately is how one approved plan turns into four unapproved ones. Forgetting it is worse.

So /issue files it and moves on. The mechanism is adm issue rather than a curl in the prompt, for the same reason /adm wraps adm run: building the request is code that can be tested, while an agent escaping quotes and newlines into a JSON body works until the first backtick. The agent's job is the part only it can do — a title and a body still worth reading in six months.

adm issue derives the forge from $GIT_REMOTE rather than storing the address again, the same single-source rule 020 applies to the host and port. Forgejo's API is GitHub-shaped, so the body and the response fields are identical on both and only the base URL and authorization scheme differ; $FORGE_API overrides the derivation for a GitHub Enterprise /api/v3 or a Forgejo behind a subpath. It also refuses to send a title or body containing a value that is encrypted in the env and absent from the source — the same rule the export uses, for the same reason.

adm issue also comments on an issue (-c N) and closes one (-x N, with -b as the closing comment). Those were missing for longer than they should have been. The skill above tells the agent to file issues as it notices things and adm land opens one per landing, so the repo accumulated a record of outstanding work with no way to say any of it was done — and it got said by hand-rolled POSTs instead, which is the curl in a prompt this section argues against, and which skip the secret check entirely. In comments, which is where failure output and key names go.

The refusal below is the other half, and the more important one. Every positional is part of the title, so adm issue close 40 filed a new issue titled "close 40": asking for a subcommand that did not exist created something rather than refusing. A title that reads as an instruction about an issue is refused now, and --force-title is there for the rare case where it really is the title you want.

verify: issue-refusals
cd "$ADM_ROOT"
# Each of these must refuse. -n throughout, so a guard that fails to fire
# files nothing -- but it would still print, which is why stdout goes nowhere.
for bad in "close 40" "40" "comment 7" "reopen #12"; do
  # shellcheck disable=SC2086
  if adm issue -n $bad </dev/null >/dev/null 2>&1; then
    echo "adm issue accepted '$bad' as the title of a new issue"; exit 1
  fi
done
# </dev/null is not decoration. --force-title gets past the guard, so this
# reaches the body, and adm issue reads the body from stdin when -b is absent
# and stdin is not a tty -- which is every verify block. Without it this
# blocked on an inherited stdin for two and a half minutes inside adm land,
# looking exactly like a hang, because the verify runs with its output
# captured and so says nothing while it waits.
adm issue -n --force-title "close 40" -b '' </dev/null >/dev/null 2>&1 \
  || { echo "--force-title did not get past the guard"; exit 1; }
adm issue -n -x 40 a title here </dev/null >/dev/null 2>&1 \
  && { echo "a number and a title together was accepted"; exit 1; }
adm issue -n -c 40 </dev/null >/dev/null 2>&1 \
  && { echo "an empty comment was accepted"; exit 1; }

# A comment carrying a secret must refuse. This had no test on any path:
# _refuse_if_secret is what stands between an agent and a token in an issue,
# and nothing had ever asked it to fire.
#
# stdout is discarded for the whole pipeline deliberately. The body *is* the
# secret and a dry run prints the body, so if this guard ever stops firing
# the test has to fail without the value reaching a terminal or a run log.
# Reading stderr is safe because adm writes key names there, never values.
err="$(sops exec-env "$ADM_BOX_DIR/$BOX_NAME.env" \
         'printf %s "$LLM_API_KEY"' | adm issue -n -c 1 2>&1 >/dev/null)" \
  && { echo "a comment containing an encrypted env value was accepted"; exit 1; }
grep -q 'refusing to send' <<<"$err" \
  || { echo "the comment was refused, but not by the secret check:"
       sed -n 1p <<<"$err"; exit 1; }
echo "adm issue refuses: instruction-shaped titles, number with title, empty comment, secret in a comment"

The runbooks skill

adm list says what this box runs and what else it could; adm enable and adm disable edit the opt-in list. The judgement left over is mapping a request — "I want to be able to roll back a bad boot" — onto a runbook, and that is what the skill is for.

The same split as /adm over adm run and /issue over adm issue: the mechanism is code that was written once and can be tested, and only the choosing is left to an agent.

runs: runbooks-skill
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands \
          .claude/skills/runbooks
install -m 0644 /dev/stdin skills/runbooks.md <<'SKILL'
Decide which optional runbook addresses what the operator asked for, and turn
it on.

Run `adm list` first. Every runbook is there with its state: required,
optional and enabled, or optional and not — the last with what it is for and
the command that enables it. That output is the catalogue; do not read the
runbooks directory to build your own.

## Enabling is not running

`adm enable <NNN>` changes the declaration. It does not touch the box. The
runbook's verify blocks start applying immediately, and whatever they assert
only becomes true once it has run:

    adm enable 050
    adm run 050

Report both or neither. Enabling something, announcing success and leaving a
red `adm verify` behind is half a job, and the half that looks finished.

Whether verify actually fails in between depends on the runbook. One whose
work is driven by a value — `045` does nothing while `MACHINE_USERS` is empty
— verifies clean the moment it is enabled. Do not promise a failure that may
not come; run `adm verify <NNN>` and say what happened.

## Disabling reverts nothing

This is the asymmetry to be explicit about. `adm disable 080` stops `080`'s
verify blocks from running. It does not uninstall `cmark-gfm`, does not remove
`export/`, does not undo anything. The state stays and stops being described,
which makes the repo *less true about the machine* — the only operation here
that does.

So treat "turn that off" as two questions, and ask the second one:

- Leave what it did, and accept that nothing now describes it.
- `adm undo`, if the snapshot pair from its run is still around.
- Write the runbook that removes it, if it should really be gone.

## Not in the middle of something else

Both commands change what the box will become, and both are gated. If the
operator asks for something and you notice a runbook that would help, say so
and finish the work you were asked to do. Enable it where the plan covers it,
not as a side effect of a different task.

## What you cannot do

`adm enable` refuses a required runbook and a number that does not exist, and
says which numbers are available. Take the refusal at face value rather than
editing `<box>.env` — hand-editing it is how this repo once made the whole
file undecryptable.
SKILL

cmd="$(mktemp)"
{
  printf -- '---\ndescription: Choose and enable an optional runbook for this box\n---\n\n'
  cat skills/runbooks.md
  printf '\n## The request\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/runbooks.md
install -m 0644 "$cmd" .opencode/commands/runbooks.md
rm -f "$cmd"

{
  printf -- '---\nname: runbooks\ndescription: Choose and enable an optional runbook for this box, via adm list and adm enable. Use when the operator wants a capability the box does not have yet.\n---\n\n'
  cat skills/runbooks.md
} > .claude/skills/runbooks/SKILL.md
chmod 0644 .claude/skills/runbooks/SKILL.md

wc -l skills/runbooks.md .opencode/commands/runbooks.md .claude/skills/runbooks/SKILL.md
verify: runbooks-skill
cd "$ADM_ROOT"
for f in skills/runbooks.md .opencode/command/runbooks.md \
         .opencode/commands/runbooks.md .claude/skills/runbooks/SKILL.md; do
  test -s "$f" || { echo "$f missing or empty"; exit 1; }
done
# The copies are generated from one source; a hand edit to either would make
# the agent and the operator read different instructions.
# Everything after the closing --- of the frontmatter, with the blank line
# that follows it dropped. Counting lines instead made this depend on how
# many keys the frontmatter happens to have.
body() { awk 'n>=2{print} /^---$/{n++}' "$1" | sed '1{/^$/d}'; }
diff <(body .claude/skills/runbooks/SKILL.md) skills/runbooks.md >/dev/null \
  || { echo ".claude copy has drifted from skills/runbooks.md"; exit 1; }
# The three things the skill must not get wrong, asserted rather than trusted.
grep -q "does not touch the box" skills/runbooks.md \
  || { echo "the skill no longer says enabling is not running"; exit 1; }
grep -q "reverts nothing" skills/runbooks.md \
  || { echo "the skill no longer says disabling reverts nothing"; exit 1; }
adm enable 000 >/dev/null 2>&1 \
  && { echo "adm enable accepted a required runbook"; exit 1; }
echo "runbooks skill installed; adm enable refuses a required runbook"

Landing a change

adm land is the whole of #7's flow in one gated command: verify, file an issue recording the request, push the branch, open a pull request carrying the verify output, merge it, return to master.

One command because it is one decision. Four separate gated operations after a single approval is the prompt fatigue the gate exists to prevent, which is the same collision /issue hit and solved by deferral.

The merge is conditional on a green verify, and the verify runs first. That ordering is the point: a pull request whose evidence was gathered after the merge is not evidence. On a red verify nothing is published and the branch is left as the only record of why the box changed — adm land does not revert, because reverting a machine is the operator's decision and a command that both changed the box and quietly changed it back is harder to reason about than one that stops and explains.

A merge commit, not a squash. The commit messages here are the documentation; squashing moves that narrative into the forge where git log cannot see it. Upstream is the opposite case and squashes by necessity — see 020.

verify: land-refusals
cd "$ADM_ROOT"
# In a throwaway clone, not here. An earlier version checked out branches in
# the live repo, which meant a verify could change which branch the operator
# -- or the `adm land` that invoked it -- was standing on. A check must not
# move the thing it is checking.
#
# It also recursed: `adm land` runs `adm verify`, so a refusal test that
# invokes `adm land` calls it again. On master the first refusal fired and
# hid that; run from inside a landing, where HEAD is a branch, it did not.
# `ADM_LANDING` now refuses re-entry outright, and this runs with it set as
# a second line of defence.
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git clone --quiet --no-hardlinks . "$tmp/repo" 2>/dev/null \
  || { echo "could not clone the repo to test against"; exit 1; }
# The clone carries committed history; the runner under test is the one in
# the working tree, which is what every other verify checks and what is
# about to be committed. A clone also lands on whatever branch this repo is
# standing on, so master is selected explicitly.
# master first, then the overlay: copying the runner in dirties bin/adm, and
# git will not switch branches over a modified tracked file.
( cd "$tmp/repo" && git checkout -q master ) \
  || { echo "the clone has no master to test against"; exit 1; }
cp bin/adm "$tmp/repo/bin/adm"
cd "$tmp/repo"
export ADM_LANDING=1
adm() { timeout 60 "$tmp/repo/bin/adm" "$@"; }

out="$(adm land -m x 2>&1 || true)"
grep -q "already landing" <<<"$out" \
  || { echo "adm land did not refuse re-entry"; echo "$out"; exit 1; }

unset ADM_LANDING
out="$(adm land -m x 2>&1 || true)"
grep -q "works on a branch" <<<"$out" \
  || { echo "adm land did not refuse to run on master"; echo "$out"; exit 1; }

git checkout -q -b probe
if adm land -m x >/dev/null 2>&1; then
  echo "adm land published from a branch with no commits on it"; exit 1
fi

printf 'probe\n' > land-probe.txt
git add land-probe.txt
out="$(adm land -m x 2>&1 || true)"
grep -q "commit the work first" <<<"$out" \
  || { echo "adm land did not refuse an uncommitted tree"; echo "$out"; exit 1; }

echo "adm land refuses re-entry, master, a branch with no commits, and uncommitted work"
runs: issue-skill
cd "$ADM_ROOT"
install -d -m 0755 skills .opencode/command .opencode/commands \
          .claude/skills/issue
install -m 0644 /dev/stdin skills/issue.md <<'SKILL'
Record a bug or a piece of future work as an issue on this repo's forge.

Use this for something worth doing that is **not** the task in hand. The point
is to stop losing it without starting it: one approved plan should not quietly
become four unapproved ones.

## When — not in the middle

Note it as you go and file it **after** the approved work is finished, before
you report back. `adm issue` is gated, so filing one mid-task puts an approval
prompt in front of the operator that was not in the plan they approved — and
being interrupted by unplanned prompts is the exact habit the protocol in the
`adm` skill exists to prevent. One task, one approval, then the issues.

If you already know while planning that you will file one, say so in the plan.
Then it is part of what was approved and costs nothing extra.

There is no queue in the harness for this. Deferring is a decision you make,
not a mechanism that makes it for you.

## Write it for someone who has forgotten everything

That someone is usually you, in six months. A title that names the thing, and
a body with:

- **What was observed**, concretely. The command, the output, the file and
  line. "Verify is flaky" is a feeling; "agent-gate failed twice and passed on
  retry, because `opencode debug agents` returns nothing while another
  opencode is starting" is a bug report.
- **Why it matters**, which is the part that decides whether anyone picks it
  up. What breaks, for whom, and when — on the next replay, on a rebuild, on a
  box without a hypervisor.
- **What you would check first**, if you already know. A guess, named as one,
  is worth more than nothing and costs the reader nothing to discard.
- **What you could not determine**, said plainly rather than smoothed over.

Keep the runbook conventions in mind: this repo values the reason over the
change, and an issue is mostly reason.

## Then file it

    adm issue "<title>" -b "<body>"

Or pipe the body in, which is easier for anything multi-line:

    adm issue "<title>" <<'EOF'
    ...
    EOF

Add `-n` to see exactly what would be sent without sending it.

## Do not

- Fix the thing instead of filing it, unless the operator asked you to.
- File it mid-task and make the operator answer a prompt they did not plan for.
- File a duplicate without saying so. Read the open issues first.
- Put a secret in a title or body. `adm issue` refuses values that are
  encrypted in `<box>.env` and absent from the source, but that check is a
  backstop and not a licence.
- File an issue to record something a runbook should say. If the knowledge
  belongs in the repo, write it there instead.
SKILL

cmd="$(mktemp)"
{
  printf -- '---\ndescription: File a bug or a piece of future work as an issue on the forge\n---\n\n'
  cat skills/issue.md
  printf '\n## What to record\n\n$ARGUMENTS\n'
} > "$cmd"
install -m 0644 "$cmd" .opencode/command/issue.md
install -m 0644 "$cmd" .opencode/commands/issue.md
rm -f "$cmd"

{
  printf -- '---\nname: issue\ndescription: File a bug or future work as an issue on this repo'"'"'s forge, via adm issue. Use when something worth doing is not the task in hand.\n---\n\n'
  cat skills/issue.md
} > .claude/skills/issue/SKILL.md
chmod 0644 .claude/skills/issue/SKILL.md

wc -l skills/issue.md .opencode/commands/issue.md .claude/skills/issue/SKILL.md
verify: issue-skill
cd "$ADM_ROOT"
for f in skills/issue.md .opencode/command/issue.md \
         .opencode/commands/issue.md .claude/skills/issue/SKILL.md; do
  test -s "$f" || { echo "$f missing or empty"; exit 1; }
  grep -qF 'adm issue' "$f" \
    || { echo "$f does not tell the agent how to file one"; exit 1; }
done
grep -q '\$ARGUMENTS' .opencode/commands/issue.md \
  || { echo "the opencode command takes no argument"; exit 1; }
grep -q '^name: issue$' .claude/skills/issue/SKILL.md \
  || { echo "the claude skill lacks a name"; exit 1; }

# The plumbing, without sending anything. Derivation from GIT_REMOTE is the
# part most likely to rot: a forge rename changes the remote and this has to
# follow it rather than keeping a second copy of the address.
out="$(adm issue "plumbing check" -b "body" -n)" \
  || { echo "adm issue -n failed"; exit 1; }
grep -q 'would POST to: https://.*/repos/.*/issues' <<<"$out" \
  || { echo "adm issue did not derive an issues endpoint:"; echo "$out"; exit 1; }
tok="$(sops -d --input-type dotenv --output-type dotenv \
       "$ADM_BOX_DIR/$BOX_NAME.env" \
      | awk -F= '$1=="FORGE_TOKEN" {print substr($0, 13)}')"
if [[ -n "${tok// /}" ]] && grep -qF -- "$tok" <<<"$out"; then
  echo "adm issue -n printed the forge token"
  exit 1
fi
echo "issue skill installed; adm issue derives $(sed -n 's/.*would POST to: //p' <<<"$out")"

Smoke test — two turns, not one

The blocks above assert the harness is installed. This asserts it works, and it runs two turns deliberately.

A single-turn test cannot detect the failure this runbook exists to document. The openai provider passed a one-turn smoke test while being fundamentally broken: the bug lives in how prior assistant messages are serialised back into the next request, so turn one — which has no history to serialise — always succeeds. The harness was reported working, and failed on the user's second prompt.

Any conversational test that never builds history is testing the easy half.

Both runs pass --auto. Today that changes nothing, because headless runs do not consult the permission ruleset at all — see the gate section above. It is there so this block stays correct if that ever changes: a verify block runs with stdin closed, and the day opencode run begins honouring ask is the day an unflagged health check starts hanging until its timeout instead of reporting. These runs exercise the wire protocol, not the gate.

verify: agent-smoke
command -v opencode >/dev/null || { echo "opencode not installed"; exit 1; }
cfg="$HOME/.config/opencode/opencode.json"
# Refuse to exercise an unpinned agent: without this the test can pass by
# using a default gateway, certifying a configuration nobody chose.
test -f "$cfg" && jq -e '.model // empty' "$cfg" >/dev/null 2>&1 \
  || { echo "no provider pinned; not running the agent (see opencode-config)"; exit 1; }

one="$(timeout 150 adm agent -- run --auto 'Reply with exactly: harness ok' 2>&1 </dev/null || true)"
if [[ -z "$one" ]]; then
  echo "no output in 150s -- the usual cause is a vCPU without AVX2"
  echo "see the agent-cpu-support block in this runbook"
  exit 1
fi
grep -qi "harness ok" <<<"$one" \
  || { echo "turn 1 did not answer as asked:"; printf '%s\n' "$one" | tail -5; exit 1; }

# Turn 2 replays turn 1's assistant message back to the endpoint. This is the
# turn that fails when the provider speaks the wrong protocol.
two="$(timeout 180 adm agent -- run --auto 'Reply with exactly: still ok' --continue 2>&1 </dev/null || true)"
grep -qiE "validation error|input_text|output_text" <<<"$two" && {
  echo "turn 2 was rejected by the endpoint -- wrong wire protocol?"
  printf '%s\n' "$two" | head -4
  echo "see 'Which built-in provider to override' in this runbook"
  exit 1; }
grep -qi "still ok" <<<"$two" \
  || { echo "turn 2 did not answer as asked:"; printf '%s\n' "$two" | tail -5; exit 1; }

echo "agent answered two turns using $(jq -r .model "$cfg"); conversation history round-trips"

Running it

tmux new -s agent          # detachable: an agent run outliving your SSH session
adm agent                  # interactive, secrets injected
adm fix 'enable a firewall allowing only ssh'   # or go straight to work
/adm enable and configure a firewall allowing only ssh

Expect one prompt per task, when the agent reaches adm run; approving it approves that whole runbook. Being asked more often than that is a signal, not a setting: something is mutating the box outside adm run, which is a bug in the runbook being written rather than in the gate. /adm is also where the agent is told to show its plan first — see skills/adm.md.

tmux is not decoration here — it is why 060 installs it. A long agent run tied to an SSH connection dies with the connection.

080 — The rendered document

Optional — a box opts in by adding 080 to OPTIONAL_RUNBOOKS in its <box>.env.

Render the whole repo as one self-contained page and publish it behind a private link, so someone who will never log into this box can read what it is -- and rebuild it from the page if the box is gone.

AGENTS.md requires every runbook to be generic, with anything true of one box rather than of the procedure coming from <box>.env. That is the right rule and it has a cost: reading runbooks/ means reading $GIT_REMOTE and $ADMIN_USER and holding the substitutions in your head. The export is where that cost is paid back — generic source, concrete render.

What goes in, and what is held back

Every value the export substitutes is one that is already plaintext in <box>.env, and every value it redacts is one that was encrypted. The redaction set is not a list maintained here; it is derived from .sops.yaml's unencrypted_regex, which is the repo's existing and enforced statement of what is secret. 015 encrypts anything new by default, so a key added tomorrow is redacted tomorrow without anyone remembering to add it anywhere.

A hand-kept blacklist was the obvious alternative and is a worse idea. It would be a second source of truth about what is secret, and its failure mode is the bad one: add a secret to the env, forget the blacklist, render it into a file, publish the file. This repo has already produced that exact shape of bug three times — a duplicated check_env, duplicated filter patterns, and an embedded SOPS policy that silently disagreed with .sops.yaml — and the third was live and wrong for several commits.

Derivation is still not enough on its own, because it only governs substitution. Nothing stops a runbook from printing a secret in its own output. So after rendering, the exporter searches the result for every decrypted value and refuses to write the file if one appears. That makes "secrets never reach the export" a checked property rather than an intention.

Why one file

A single self-contained document, assets inlined. One object to publish, one pre-signed URL to read it with, and every cross-reference is an #anchor rather than a link to another object. A private multi-page site on object storage needs either a public bucket or an authenticating proxy in front, because pre-signed URLs are per-object and relative links between pages cannot carry a signature — click through and the second page is a 403.

The renderer

cmark-gfm: the CommonMark reference implementation with GitHub's extensions, which is what gets the tables in README.md and DECISIONS.md rendered. A 400 KiB C binary from the official repositories with no runtime dependencies beyond libc — in keeping with 060's refusal of anything needing npm.

adm does not hand whole files to it. CommonMark renders a fenced block's info string as class="language-bash" and discards the rest, which would throw away id= and verify — precisely the attributes that separate a command this box actually ran from an illustration. adm already parses runbooks into blocks, so prose goes through cmark-gfm and blocks are rendered by adm itself, labelled with what they are.

runs: export-renderer
sudo pacman -S --needed --noconfirm cmark-gfm
cmark-gfm --version | head -1
verify: export-renderer
command -v cmark-gfm >/dev/null || { echo "cmark-gfm not on PATH"; exit 1; }
# Tables are a GFM extension rather than core CommonMark, and this export
# needs them, so assert the extension is present rather than just the binary.
printf '| a | b |\n| - | - |\n| 1 | 2 |\n' \
  | cmark-gfm -e table | grep -q '<table>' \
  || { echo "cmark-gfm cannot render tables; is the table extension missing?"
       exit 1; }
echo "cmark-gfm $(cmark-gfm --version | head -1 | awk '{print $2}') renders GFM tables"

Rendering it

adm export                 # -> export/<box>.html, gitignored
adm export -o /tmp/x.html

export/ is gitignored. The document is generated, a third of a megabyte, and would churn on every commit; the repo already contains everything it is made from. It is published rather than committed — see the publish section when it exists.

Not included: hosts/<box>/facts/ and hosts/<box>/log/. The export renders the intent — the prose and the commands — rather than the machine's recorded state, and facts/ holds internal addresses and listening sockets that have no business in a document whose whole purpose is being easy to pass around.

verify: export-renders
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
err="$(mktemp)"
adm export -o "$out" >/dev/null 2>"$err" \
  || { echo "adm export failed"; cat "$err"; rm -f "$out" "$err"; exit 1; }

# The highlighter checks every block it colours by stripping the markup back
# off and comparing with what it was given; on any mismatch it emits the block
# plain and says so. These are commands someone may paste into a root shell,
# so a highlighter that drops a quote is worse than none -- that check is the
# reason this is safe, and this asserts it never had to fire.
if grep -q 'highlighter' "$err"; then
  echo "the highlighter could not colour some block without altering it:"
  grep 'highlighter' "$err" | head -3
  rm -f "$out" "$err"; exit 1
fi
rm -f "$err"

# And that it is doing anything at all. A highlighter that silently stopped
# matching would leave a correct, entirely grey document.
# The copy buttons exist only as script, so there is no markup to look for.
# Assert the script that builds them is still in the page: losing it would
# leave a document that looks entirely correct and silently cannot be copied.
for want in codewrap clipboard execCommand textContent; do
  grep -qF -- "$want" "$out" \
    || { echo "the copy-button script is missing \"$want\""; rm -f "$out"; exit 1; }
done

for cls in 'class="cmd"' 'class="s"' 'class="c"' 'class="v"'; do
  grep -qF -- "$cls" "$out" \
    || { echo "no $cls spans in the export; highlighting is not running"; \
         rm -f "$out"; exit 1; }
done

# Structure, so a renderer that silently stopped emitting tables or blocks is
# caught rather than producing a plausible-looking shorter document.
for want in '<table>' 'class="blk run"' 'class="blk verify"' '<section id='; do
  grep -qF -- "$want" "$out" \
    || { echo "export is missing $want"; rm -f "$out"; exit 1; }
done

# The property that matters. adm export already refuses to write a file
# containing a decrypted value, so this asserts the same thing independently:
# if the gate inside adm ever stopped working, this would still fail.
leaked=0
while IFS='=' read -r k v; do
  case "$k" in ''|'#'*|sops_*) continue ;; esac
  case "$v" in 'ENC['*) : ;; *) continue ;; esac   # encrypted keys only
  val="$(sops -d --input-type dotenv --output-type dotenv \
         "$ADM_BOX_DIR/$BOX_NAME.env" | awk -F= -v k="$k" '$1==k {print substr($0, length(k)+2)}')"
  [[ -z "${val// /}" ]] && continue
  if grep -qF -- "$val" "$out"; then
    echo "the export reproduces the decrypted value of $k"
    leaked=1
  fi
done < "$ADM_BOX_DIR/$BOX_NAME.env"
# Every heading links to itself, and to *itself* rather than to whichever
# anchor happened to be nearby -- a self-link pointing at the wrong id is
# worse than none, because it copies cleanly and lands somewhere else.
heads="$(grep -co '<h[1-6] id=' "$out")"
mismatched="$(python3 - "$out" <<'PY'
import re, sys
s = open(sys.argv[1], encoding="utf-8").read()
bad = 0
for tag, hid, inner in re.findall(r'<(h[1-6]) id="([^"]+)">(.*?)</\1>', s, re.S):
    if ('href="#%s"' % hid) not in inner:
        bad += 1
print(bad)
PY
)"
test "$mismatched" = 0 \
  || { echo "$mismatched heading(s) do not link to their own id"; rm -f "$out"; exit 1; }

# No anchor anywhere in the document may point at an id that is not in it.
dead="$(python3 - "$out" <<'PY'
import re, sys
s = open(sys.argv[1], encoding="utf-8").read()
ids = set(re.findall(r'\sid="([^"]+)"', s))
print(len({h for h in re.findall(r'href="#([^"]+)"', s) if h not in ids}))
PY
)"
test "$dead" = 0 \
  || { echo "$dead dead anchor(s) in the export"; rm -f "$out"; exit 1; }

# Self-containment, which is the property the whole single-file design rests
# on: a pre-signed URL authorises one object, so anything the page fetches
# from elsewhere is a request that cannot be signed and will not load. An
# earlier version of this asserted the page carried no script at all, which
# was the wrong invariant -- two behaviours genuinely depend on when the page
# is read -- and it is the *external reference* that breaks the design, not
# the script tag.
if grep -qE '<script[^>]+src=|<link[^>]+stylesheet|<img[^>]+src="https?:' "$out"; then
  echo "the export references something it would have to fetch:"
  grep -oE '<script[^>]+src=[^ >]*|<link[^>]+stylesheet[^>]*|<img[^>]+src="https?:[^"]*' "$out" | head -5
  rm -f "$out"; exit 1
fi

sections="$(grep -co '<section id=' "$out")"
size="$(( $(stat -c %s "$out") / 1024 ))"
rm -f "$out"
test "$leaked" = 0 || exit 1
echo "export renders $sections sections, $heads self-linking headings, no dead anchors, ${size} KiB, no decrypted value in it"

The page is also the repository

The document carries its own sources: every markdown file it was rendered from, in <template> elements the browser does not display, plus bin/adm itself in an appendix. With those, the page can be mined back into a working repository — everything except the git history, which only the bundle has.

<template> rather than a <script> block, because its contents are parsed as HTML: entities decode normally for anything reading it in a browser, and a plain-text extractor can unescape the same bytes without one. A <script> would have neither property and would break outright on a literal </script> in the text.

Why the rendered page cannot be mined instead

This is the part worth being clear about, because it looks like duplication for nothing. adm parses markdown fences, and the blocks you can read on the page have already had this machine's environment substituted into them — /opt/admin where the source says $ADM_ROOT, rea-dev where it says $BOX_NAME. A runbook reconstructed from the visible text would be specific to this box, which is the one thing AGENTS.md forbids. The templates hold the generic originals; the rendered page holds what actually ran. Both are wanted, and they are not the same bytes.

It costs about 300 KB, taking the document past a megabyte. That is the price of an artifact that can be printed, mailed or carried on a stick and still turn back into the thing it describes.

The document knows its own address

The rebuild directive is meant to be pasted with nothing to edit, which means the page has to carry a working link to itself. That requires signing index.html before the render that quotes it is uploaded — which works only because a signature covers the key and not the content, the same property that lets one link keep returning the current document.

rclone will not sign a key that does not exist, so the order is: sign the object still sitting there from last time, render the new document around that link, then replace the object. The signature stays valid across the replacement. The very first publish of a box has nothing to sign, so the directive falls back to telling the reader to supply the file themselves.

The link that gets printed is the same one embedded in the page. Minting a second would be two answers to one question, and they would expire at different moments.

What it does not carry

Everything that belongs to a particular machine: its <box>.env, its age private key, its facts, its AS-FOUND.md. Those are absent by design, not by omission — the first two must never be published, and the last two are records of this box rather than instructions for any box. What comes out of the unpacker is the generic half: runbooks, conventions, decisions, and the runner. 000 is where a machine of your own begins.

Asserting it rather than claiming it

export-round-trips renders the document, pulls every embedded source and the runner back out, and compares each with the file it came from — then checks that the sha256 printed beside the runner is the one a reader would actually compute. An escaping bug anywhere on that path would otherwise surface as a repository that looks right and does not run, which is the worst way to find out. It catches a missing trailing newline.

Colouring the commands

Highlighting happens at render time, in adm, with no library. Partly because a client-side highlighter would be a dependency to vendor into a file that has to stay self-contained, and partly for the same reason everything else here is decided when the page is written: the language of a block does not depend on who is reading it.

It is a highlighter, not a parser, and it works one line at a time. That is the decision that matters. Shell is full of things a line scanner gets wrong — an awk program quoted across three lines, a heredoc holding something that is not shell — and the failure mode of a stateful scanner is that one stray quote paints everything after it as a string, for the rest of the block. Per line, any such mistake stops at the end of that line.

The contextual part is knowing where a command can start: the beginning of a line, and after |, &&, ;, (, do, then. That is what makes the first word of a pipeline read as a command and the rest as arguments. Words that run other commands — sudo, env, xargs, timeout — pass that position along, so sudo pacman -S colours both rather than making pacman look like an argument to sudo.

The check that makes it safe

These are commands someone may paste into a root shell, so a highlighter that drops a quote is worse than no highlighter. Every block is therefore stripped back to plain text and compared with what went in, on every render. On any mismatch the block is emitted plain and adm says so on stderr — the failure is safe by construction rather than by inspection, and export-renders asserts the check never had to fire.

What it declines to guess

Executable blocks are known to be shell: parse_runbook only returns bash blocks with an id=. Illustrative fences carry no language, so they are sniffed, and the sniff is deliberately timid — a wrong guess colours the wrong words and leaves the reader working out which parts to disbelieve. JSON is detected by parsing it, which is exact. A block whose first line reads name: value is left alone: in this repo those are a .sops.yaml fragment, a setfacl error and an opencode validation failure, and none is improved by pretending to understand it. Everything else is treated as shell.

The markup costs about 170 KiB on a 400 KiB document — spans around roughly five thousand tokens. Still one file, still no request.

Copying one

Every block gets a copy button, built by the page's script rather than rendered into it. That sounds backwards given everything above, and it is the same rule: a button that copies needs a clipboard, so one rendered into the HTML would be a dead control for a reader without JavaScript. Built at read time, it exists only where it can work — and it keeps about 17 KB of identical markup out of a document with 177 code blocks.

It copies textContent, not innerText, because the block is preformatted and innerText would normalise away the whitespace that makes it a runnable command. What you get is exactly what is on screen, including any ‹redacted› marker, which is the honest thing to hand over: the command as this box ran it, with the secret still missing.

The button says Copied in green for a moment and goes back to Copy. If the clipboard is unavailable it says Press ctrl-C in red instead. That case is real rather than theoretical: the async clipboard API needs a secure context, which the published document has over https and the same file opened from disk may not — which is how anyone reviewing an export before sending it is reading it. There is a fallback for that, and if the fallback fails too the button says so rather than quietly looking like it worked.

Finding your way around

The document is long, so the contents are always reachable — in two forms, chosen by width.

Wide enough and there is a tray down the right-hand side, always open. The page itself does not scroll at all: html is overflow: hidden and the two columns each scroll on their own, so the document's scrollbar sits at the right edge of the text it scrolls and the tray's at the right edge of the window. A sticky tray inside a scrolling page puts the two scrollbars side by side, which reads as one broken double-width bar.

Narrow and the tray is gone, replaced by a book button fixed in the top-right corner. It was an inline collapsible box before, which is something you scroll past once and then cannot find again. Fixed means always where you left it. Picking an entry closes the panel, because it is laid over the page and would otherwise cover the heading it just jumped to.

Every heading is also a link to itself, so getting a link to a section means right-clicking its title rather than hunting for it in the contents. The id lives on the heading, not the anchor, so every cross-reference written for the forge still resolves here.

Publishing it

The rendered document goes to S3-compatible object storage — DigitalOcean Spaces here — as one private object, read through a pre-signed URL.

That shape follows from wanting it private. A pre-signed URL authorises one object, and relative links between pages cannot carry a signature, so a private multi-page site needs either a public bucket or an authenticating proxy in front of the CDN. One self-contained file sidesteps both: every cross-reference is an #anchor, one signature covers the whole document, and "replace the previous publish" is overwriting a single key rather than a sync that has to work out which objects are now orphans. The orphan problem is not solved here, it is absent.

The second artifact: the repo itself

The document says what this machine is. It does not let you rebuild it. So adm publish uploads a second object beside it — <box>.bundle, a git bundle --all, which is the whole repository as one file:

rclone copyto pub:<bucket>/<box>/<box>.bundle ./rea-dev.bundle
git clone rea-dev.bundle rea-dev

Every commit, every runbook, and the encrypted env file, from an object store that has never heard of git. That is the artifact you want when the machine is gone, the forge is unreachable, or both — the case where a document describing the machine is interesting and a repo you can clone is useful.

It is never signed. The document is the artifact that travels, and a pre-signed link travels with it: forward the link, forward everything it reaches. The document carries ciphertext and public keys; the bundle carries this machine's facts/, AS-FOUND.md and run journal in the clear. Those are not the same thing to hand a stranger, so only one of them is reachable by link, and the bundle is fetched with the credentials adm publish already uses. The document names the object and states the commit it was itself built from, so an operator holding both can tell which is older — the two are replaced on every publish and nothing makes a reader re-open the page.

A bundle carries commits and nothing else, so it stops at HEAD while the document renders the working tree. When those disagree, publish says so rather than letting a clone silently lag what someone just read.

Why there is no sha sidecar

The obvious way to know whether the bundle needs re-uploading is to store the HEAD sha beside it and compare. It is unnecessary: for a single-part upload an object's ETag is its MD5, so the content hash is already stored with the object, and rclone --checksum compares against it. The server is asked whether these bytes are already there, which is a question it can answer exactly.

A sidecar would be a second record of the same fact, with the failure mode that implies — the object lands and the sha does not, and now the box believes something false about the bucket. The sha is still recorded, in the document, as the commit it reflects. That is provenance, which is a reader's question, not a transfer decision.

This is also why --checksum is not optional. rclone's default comparison is size plus modtime, and publish rebuilds both artifacts every run, so the mtime always differs even when the bytes are identical — every publish re-uploaded everything. And it is why the bundle is built with pack.threads=1: threaded delta compression picks different deltas from run to run, so an unpinned bundle of identical history hashes differently each time and would be re-uploaded forever.

What a pre-signed URL actually grants

Both of these were tested against Spaces rather than reasoned about, because the answers decide how links may be handed out.

Replacing the object does not invalidate the link. The signature covers the method, the key and the expiry — not the content, and not a version. The same URL fetched version one, the object was overwritten, and the same URL then returned version two. So a link is a subscription to that key for its whole window: --expire 7d gives someone a week of always-current reading, and every publish silently updates what they already have open.

That cuts both ways. A link cannot be withdrawn once given — there is no revoking a signature. Shortening the window is the only control, and the floor on it is how long you are willing for the holder to keep reading. Rotating PUBLISH_S3_ACCESS_KEY invalidates every outstanding link at once, which is the blunt instrument available if one escapes.

The default is --expire 7d, which is also the maximum: Spaces allows no longer, and asking for more gets silently clamped rather than refused — 8d and 30d both come back as X-Amz-Expires=604800. rclone mentions the clamp on stderr, so adm publish repeats it rather than letting a request for a month quietly become a week.

Seven days because the links are the point. The objects do not expire, only the links to them, so the cost of a short window is having to publish again to hand out a readable one — and a publish with nothing to upload now skips both transfers and still mints fresh links, which makes re-issuing cheap. Use --expire 1h when the link is for someone reading it immediately.

One signature authorises exactly one key. Taking the document's URL and editing the path to the bundle returns 403 SignatureDoesNotMatch. The two artifacts are therefore two separate grants: handing someone the document does not hand them the repo, and that is a feature worth keeping rather than a gap to close.

That property used to be load-bearing in the other direction: the document quoted a signed link to the bundle, so adm publish had to upload the bundle and sign it before rendering the page around that link. It does not any more. The bundle is uploaded and left unsigned, and the only signature in a publish is the document's own.

Which is a reduction worth being explicit about. Two grants became one, and the one that remains reaches the artifact built to be read rather than the one holding the machine's record in the clear.

This costs the document its checksum skip. A signature embeds the minute it was made, so a published render differs from the previous one even when nothing else changed, and --checksum always transfers it. That is the price of the link being live, and it is one 390 KiB object; the bundle, which has no signature anywhere near it, still skips when unchanged. A plain adm export embeds no link and so remains byte-identical run to run.

The embedded link also puts PUBLISH_S3_ACCESS_KEY into the document, because SigV4 carries the access key id in X-Amz-Credential and has nowhere else to put it. The export's leak gate would refuse that, so it is given the narrowest possible exemption: the exact URL adm publish embedded is removed from the page before scanning, and nothing else is. The same value appearing anywhere else is still a leak and still refused. Worth being clear about what is and is not exposed — the secret key never appears in a signed URL, only a signature derived from it, so it stays fully checked; and anyone holding a link to the document already has the access key id in their address bar, though a saved copy of the page carries it onward where a bare URL would not.

Whether a signature has lapsed depends on when the page is read, which is the one thing a statically rendered document cannot know. So this is the single exception to the no-script rule — an inline script, no external request, the page still one file — which reads X-Amz-Date and X-Amz-Expires out of the embedded URL and says either

Link good for another 6 days.

in green, or

Link expired 3 days ago.

in red. Without JavaScript the sentence stays as rendered: signed at such a time, for seven days.

It reports what the signature says, not whether the link works. Rotating PUBLISH_S3_ACCESS_KEY invalidates every outstanding URL while leaving its dates untouched, and the reader's own clock is taken on trust. Green means "not yet expired according to its own fields", which is weaker than verified and is worded that way.

History leaves by a different door

A bundle carries all history, including a blob committed and removed again. The pre-push hook cannot help: publish talks to the object store directly, so no push happens. Before building a bundle, adm publish therefore runs hooks/scan-history — the same secret-scanning library the git hooks use, over every blob reachable from every ref — and refuses to build if anything is in the clear. See 015 for the scanner and for the list of blobs reviewed and allowed.

--html-only skips the bundle and publishes the document alone.

Why rclone, and why its environment

rclone is a single static Go binary from extra with no runtime dependencies, and sync/copyto are native verbs rather than a wrapper around a storage API. The deciding reason, though, is credentials: rclone can configure a remote entirely from environment variables (RCLONE_CONFIG_<name>_*), so the keys arrive through sops exec-env into one process and never touch a config file. aws-cli and s3cmd both want a credentials file or a long command line.

That distinction matters more than it sounds. A command line is visible to every user on the box through /proc/<pid>/cmdline; an environment is visible only to the process owner and root. Neither is a vault, but putting a secret key in argv would publish it to anyone with a shell here, which is the same reasoning 070 uses for the agent's API key.

What the box needs

Five values in <box>.env, all encrypted — none of them is needed before 015 runs, so the default applies and nothing has to be exempted:

key what
PUBLISH_S3_ENDPOINT regional endpoint, e.g. https://nyc3.digitaloceanspaces.com
PUBLISH_S3_BUCKET the Space
PUBLISH_S3_PREFIX key prefix; defaults to $BOX_NAME
PUBLISH_S3_ACCESS_KEY Spaces access key
PUBLISH_S3_SECRET_KEY Spaces secret

Add them with sops rather than by editing the file — see 015, which explains at length what hand-editing does to it.

Two documents, two audiences

adm export renders this machine: box values substituted, encrypted values shown as ‹redacted›, the box's own ciphertext embedded, every source file carried so the page can be mined back into a repository. It answers how do I get this machine back, and it is private because the answer describes one machine in detail.

adm export --generic renders the product. No box, so nothing to substitute and nothing to redact: per-box values appear as the placeholders the runbooks are written with, $ADM_ROOT and ${BOX_NAME}, because that is the form you would write a new runbook in. It answers how do I get a machine, and it is public — it is intended for GitHub Pages on the upstream repository, where someone who has never seen this project arrives.

It carries no sources and no appendix. Those exist so a document handed over as a link can be reconstructed into a repository, which matters when cloning is not an option; the generic document's second step is git clone, from a public URL it derives from the remote. Embedding would double the page for a property the reader already has. The result is about half the size.

The leak gate still runs, over an empty set of values. A gate skipped because "there are no secrets in this one" is a gate that stops noticing when that changes.

Whatever builds the public page needs cmark-gfm as well as Python — adm shells out to it for prose — which is the one dependency a CI runner will not have by default.

.github/workflows/pages.yml is what builds it: on every push to the public repository's master, it installs cmark-gfm, renders with adm export --generic, checks the result is the document rather than merely a file, and deploys to Pages. Publishing the page needs no credential of ours — Actions deploys to its own repository's Pages — so unlike adm publish there is nothing here to keep secret.

Two things about that file are worth knowing. It has to be enabled once by hand: the repository's Pages source must be set to GitHub Actions, which is a setting rather than a file. And its job is guarded on github.server_url == 'https://github.com', because this same file is synced into the private fork and Forgejo reads .github/workflows too — without the guard, every push to the fork queues a job that cannot deploy to Pages and may have no runner to say so. The guard is on the server rather than the repository name, since the forge's address is the one thing that distinguishes the two.

verify: generic-export
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export --generic -o "$out" >/dev/null 2>&1 \
  || { echo "adm export --generic failed"; rm -f "$out"; exit 1; }

# The placeholders have to survive *inside executable blocks*. That is the
# whole of what "generic" means here: the blocks read as written rather than
# as they ran, so a new runbook can be written against them.
#
# Inside blocks specifically, and not by grepping the page, because the first
# two versions of this check did grep the page and both passed when they
# should have failed. $ADM_ROOT appears in prose that documents the
# placeholder, and in the private render it appears fifty-two times inside
# the embedded <template> sources -- so a whole-page grep answers yes no
# matter what substitution did. The blocks are the only place the question
# has meaning.
PYTHONDONTWRITEBYTECODE=1 python3 - "$out" <<'PY'
import html, io, re, sys
page = io.open(sys.argv[1], encoding="utf-8").read()
blocks = [html.unescape(re.sub(r"<[^>]+>", "", m.group(1)))
          for m in re.finditer(
              r'<div class="blk [^"]*">.*?<pre><code>(.*?)</code></pre>',
              page, re.S)]
if not blocks:
    raise SystemExit("no executable blocks rendered at all")
kept = [b for b in blocks if "$ADM_ROOT" in b]
if len(kept) < 10:
    print("%d of %d blocks kept $ADM_ROOT; a generic render substitutes"
          % (len(kept), len(blocks)))
    print("nothing, so almost every block that uses it should still show it.")
    raise SystemExit(1)
print("%d of %d blocks keep $ADM_ROOT unsubstituted" % (len(kept), len(blocks)))
PY

# Deliberately NOT asserting the box name is absent. That was the first
# version of this check and it failed immediately, correctly: the name
# appears twenty-two times in prose -- README and DECISIONS tell the story of
# this box being the first one, and several runbooks explain decisions by
# naming it. All of that is published upstream already, which is why
# generic-runbooks checks executable blocks for a box name and not prose.
#
# So the risk this block guards is not "the name appears" but "a value was
# substituted", and the placeholder above is the evidence for that. A check
# that looks obviously right can be wrong about what the repo actually
# promises; this one was.

# Neither of the two things the private document carries for reconstruction.
for pattern in 'class="src" data-path' 'id="bin-adm"'; do
  if grep -qF "$pattern" "$out"; then
    echo "the generic render embeds sources ($pattern); it should not"
    rm -f "$out"; exit 1
  fi
done

# The directive says create, not restore.
grep -qF 'Create a machine from these runbooks' "$out" \
  || { echo "the generic render has no creation directive"; rm -f "$out"
       exit 1; }

# Titled for the project, not for a box that does not exist. This read
# "adm - adm" on the published page for a while: the per-box template with
# the box name standing in for the project. Taken from README's own first
# heading, so the check is that the two agree rather than that the title
# matches a string written here as well.
want="$(sed -n 's/^# //p' README.md | head -1)"
test -n "$want" || { echo "README has no h1 to take a title from"
                     rm -f "$out"; exit 1; }
grep -qF "<title>$want</title>" "$out" \
  || { echo "the generic render is not titled for the project:"
       echo "  README h1: $want"
       grep -o '<title>[^<]*</title>' "$out" | sed 's/^/  render:    /'
       rm -f "$out"; exit 1; }
size=$(( $(stat -c%s "$out") / 1024 ))
rm -f "$out"
echo "generic render: ${size} KiB, placeholders intact, no embedded sources, creation directive present"

The box's own secrets, as ciphertext

The document carries hosts/<box>/<box>.env encrypted, plus the box's .age.pub and its scan-history.allow. Without them a reconstruction can rebuild the machine's configuration but not its credentials, and adm env-fill papers over that by asking a human to type the values back — which is impossible for most of them. FORGE_TOKEN, PUBLISH_S3_SECRET_KEY and LLM_API_KEY are not values an operator remembers, they are values an operator had. Asking for them means issuing new ones, and a box with new credentials is not the box this document describes.

It is ciphertext, so it changes nothing about who can read what: anyone holding this page could already read the repository it came from. What it does change is what the page is. It was semi-private documentation; it is now closer to an encrypted backup, and the seven-day limit on a pre-signed link carries weight it did not carry before.

Three files by exact name, never a directory walk. hosts/<box>/ also holds facts/, AS-FOUND.md and log/, which are plaintext machine detail — network configuration, accounts, a 24KB /etc manifest — and are precisely what reached a public repository once before. A walk with exclusions would publish anything added to that directory later. A list publishes nothing it was not told about. The bundle carries the rest, because a bundle is handed to someone deliberately where this page travels as a link.

adm export refuses outright if the env is not encrypted yet. A box that has not reached 015 has its secrets in the clear on disk, and there is no version of embedding that file which is recoverable afterwards.

verify: export-carries-ciphertext
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export -o "$out" >/dev/null 2>&1 \
  || { echo "adm export failed"; rm -f "$out"; exit 1; }

# Pull each embedded hosts/ file back out of the page and judge it the way
# the commit hook judges the real one -- check_env from the scan library,
# against the same unencrypted_regex. A second idea of what counts as
# encrypted would be free to disagree with the one that blocks commits, and
# the weaker of the two would be the one standing between this document and
# a published secret.
. hooks/secret-scan.sh
ure="$(unencrypted_regex)"
tmp="$(mktemp -d)"
found="$(PYTHONDONTWRITEBYTECODE=1 python3 - "$out" "$tmp" <<'PY'
import html, io, os, re, sys
page = io.open(sys.argv[1], encoding="utf-8").read()
names = []
for m in re.finditer(r'<template class="src" data-path="(hosts/[^"]+)">(.*?)</template>',
                     page, re.S):
    rel = html.unescape(m.group(1))
    names.append(rel)
    io.open(os.path.join(sys.argv[2], rel.replace("/", "__")), "w",
            encoding="utf-8").write(html.unescape(m.group(2)))
print(" ".join(names))
PY
)"

# Nothing from the machine's own record, ever. Named individually rather than
# as a pattern, because the point is a list of what is forbidden and not a
# guess at its shape.
for bad in facts/ AS-FOUND.md log/; do
  case "$found" in
    *"$bad"*) echo "the export carries $bad, which is plaintext machine detail"
              rm -rf "$out" "$tmp"; exit 1 ;;
  esac
done

rc=0
for f in "$tmp"/*; do
  [ -e "$f" ] || continue
  case "$f" in
    *__gitignore|*scan-history.allow|*.age.pub) continue ;;
  esac
  check_env "$(basename "$f")" "$ure" "$(cat "$f")" || rc=1
done
rm -rf "$out" "$tmp"
test "$rc" = 0 \
  || { echo "an env embedded in the export has a value in the clear"; exit 1; }
echo "export carries: $found"
echo "every embedded env is encrypted except what the policy exempts"
verify: export-round-trips
cd "$ADM_ROOT"
out="$(mktemp --suffix=.html)"
adm export -o "$out" >/dev/null 2>&1 \
  || { echo "adm export failed"; rm -f "$out"; exit 1; }

# The claim the appendix makes is that this page can be mined back into a
# working repository. Assert it rather than describe it: pull every embedded
# source and the runner out of the rendered page and compare each with the
# file it came from. An escaping bug anywhere in that path would otherwise
# surface as a repository that looks right and does not run.
python3 - "$out" "$ADM_ROOT" <<'PY'
import hashlib, html, io, os, re, sys

page = io.open(sys.argv[1], encoding="utf-8").read()
root = sys.argv[2]
bad = n = 0

def check(rel, text):
    global bad, n
    n += 1
    path = os.path.join(root, rel)
    try:
        want = io.open(path, encoding="utf-8").read()
    except OSError:
        print("  %s: embedded but not in the repo" % rel); bad += 1; return
    if text != want:
        a = hashlib.sha256(text.encode()).hexdigest()[:12]
        b = hashlib.sha256(want.encode()).hexdigest()[:12]
        print("  %s: embedded %s, on disk %s" % (rel, a, b)); bad += 1

for m in re.finditer(
        r'<template class="src" data-path="([^"]+)">(.*?)</template>',
        page, re.S):
    check(html.unescape(m.group(1)), html.unescape(m.group(2)))

m = re.search(r'<pre id="adm-source"><code>(.*?)</code></pre>', page, re.S)
if not m:
    print("  the runner is not in the page"); bad += 1
else:
    check("bin/adm", html.unescape(re.sub(r"<[^>]+>", "", m.group(1))))

# And that the digest the page prints is the one a reader would compute.
d = re.search(r"<pre><code>([0-9a-f]{64})</code></pre>", page)
if not d:
    print("  no sha256 printed beside the runner"); bad += 1
else:
    real = hashlib.sha256(
        io.open(os.path.join(root, "bin/adm"), "rb").read()).hexdigest()
    if d.group(1) != real:
        print("  the page prints a digest that is not the runner's"); bad += 1

if bad:
    sys.exit(1)
print("%d sources and the runner survive the round trip" % n)
PY
rc=$?
rm -f "$out"
exit "$rc"

Runbooks are supposed to be generic, and until now nothing checked it. The rule has two tiers, because naming a box is not equally wrong everywhere.

A box’s name may not appear in an executable block. That is code which runs, and a name baked into it makes the procedure wrong on the next machine. It never needs to be there: adm substitutes $BOX_NAME, $ADM_ROOT and every other env key before a block runs, so there are zero violations today and this is purely a ratchet.

No link may point inside a per-box directory. A link to hosts/<name>/AS-FOUND.md resolves on the one machine that owns that directory and is dead everywhere else. There were eight, two of them in the paragraph of AGENTS.md that states the rule. They are now plain code spans, because no generic link can point at a per-box file. This tier covers the documents as well as the runbooks, since all four ship in the public repo and a dead link is dead wherever it sits.

Note what this tier does not do: look for this box's name. Any link into hosts/<anything>/ fails, including one pointing at somebody else's box. That is both stricter and simpler, and it has a useful side effect — the tier cannot be confused by what a box is called.

Prose and illustrative blocks may name a box, deliberately. Substitution reaches executable blocks only — measured: zero $ADM_ROOT left in them against three surviving in illustrative ones. So writing <box> into an example would not make the published document generic, it would make it useless: sops rea-dev/rea-dev.env is a command a reader can paste, and sops <box>/<box>.env is a placeholder. The generic source lives in the templates; the rendered page is allowed to be about this machine.

If that bet turns out wrong, the fix is to move one category from permitted to failing — which is why the tiers are separate conditions rather than one regex.

When the box is called something unfortunate

Tier 1 has to search for a name, and a name can be ordinary text. Matching is on word boundaries, so a box called ls no longer matches inside tools or false — that alone took it from 97 hits to 5. But boundaries cannot help when the name is a word: echo matches 400 times, test 119, and box hits every <box> placeholder in the prose.

There is no pattern that distinguishes "the machine called test" from the shell builtin, so the check does not pretend otherwise. It reports the first ten lines and then says what is probably happening, at any count rather than past some threshold — five lines of correct shell flagged as mistakes is worse than four hundred, because the operator has no reason to suspect the check rather than their own code. The remedy is to rename the box or to list the lines in hosts/<box>/generic.allow.

Tier 2 is unaffected, being name-independent: zero false positives for every name tried, including a.

verify: generic-runbooks
cd "$ADM_ROOT"
# The forge's hostname, derived rather than stored again -- 020 does the
# same, for the same reason. It is one installation's address, and has no
# business in a file the public repo carries.
forge="$(printf '%s' "${GIT_REMOTE:-}" | sed -E 's|^[a-z+]+://||; s|^[^@]*@||; s|[:/].*$||')"
# What adm upstream refuses to publish, read from adm rather than written
# here again. Restating it is how tier 4 came to be needed at all.
never="$(adm upstream -n 2>/dev/null | sed -n 's/^never published: //p')"
test -n "$never" \
  || { echo "adm upstream did not report what it never publishes"; exit 1; }
python3 - "$BOX_NAME" "$forge" "$never" <<'PY'
import glob, io, os, re, sys

box = sys.argv[1]
forge = sys.argv[2] if len(sys.argv) > 2 else ""
never = (sys.argv[3] if len(sys.argv) > 3 else "").split()
if not box:
    sys.exit("BOX_NAME is empty; nothing to check against")

docs = ["README.md", "AGENTS.md", "DECISIONS.md"]
runbooks = sorted(glob.glob("runbooks/*.md"))

# Tier 2 does not look for this box at all: any link into a per-box
# directory is dead in every repo but the one that owns that box,
# including a link to somebody else’s. Being name-independent also
# makes it immune to a box called something ordinary.
link = re.compile(r"\]\(([^)]*hosts/[^/)<>]+/[^)]*)\)")
# Tier 1 has to look for the name, and does so on word boundaries: a
# box called "ls" would otherwise match inside "tools" and "false".
name = re.compile(r"(?<![\w-])" + re.escape(box) + r"(?![\w-])")
# Tier 3: the forge hostname, anywhere in a generic file. The export leak
# gate cannot do this -- it excuses any encrypted value already present in
# the source, so a hostname written into prose exempts itself.
host = re.compile(re.escape(forge)) if forge else None
bad = []

for path in docs + runbooks:
    lines = io.open(path, encoding="utf-8").read().split("\n")
    fence = executable = False
    for n, text in enumerate(lines, 1):
        m = re.match(r"^```(.*)$", text)
        if m:
            if not fence:
                info = m.group(1).strip()
                executable = info.startswith("bash") and "id=" in info
            fence = not fence
            continue
        if host and host.search(text):
            bad.append((path, n, "forge hostname", text.strip()))
        if fence and executable:
            if name.search(text):
                bad.append((path, n, "executable block", text.strip()))
        elif not fence:
            for g in link.finditer(text):
                bad.append((path, n, "link target", g.group(1)))

# Tier 4: files that are not markdown. They have no prose to be excused by,
# so any mention of the box is a defect -- unless adm upstream never publishes
# the file. .sops.yaml is the case that motivated this: 015 generates it with
# this box's own rule and recipients, and it reached the public repo because
# tier 1 reads executable blocks and tier 2 reads links, and neither looks at
# YAML.
import subprocess
tracked = subprocess.run(["git", "ls-files"], capture_output=True,
                         text=True).stdout.split()
for path in tracked:
    if path.endswith(".md"):
        continue
    if any(path == e.rstrip("/") or path.startswith(e.rstrip("/") + "/")
           for e in never):
        continue
    try:
        body = io.open(path, encoding="utf-8", errors="replace").read()
    except OSError:
        continue
    for n, text in enumerate(body.split("\n"), 1):
        if name.search(text):
            bad.append((path, n, "published file", text.strip()))

allowed = set()
for f in glob.glob("hosts/*/generic.allow"):
    for ln in io.open(f, encoding="utf-8"):
        ln = ln.split("#", 1)[0].strip()
        if ln:
            allowed.add(ln)

bad = [b for b in bad if "%s:%d" % (b[0], b[1]) not in allowed]
for path, n, kind, text in bad[:10]:
    why = {"executable block": "names %s in an executable block" % box,
           "link target": "links inside a per-box directory",
           "forge hostname": "names the forge host",
           "published file":
               "names %s, and adm upstream publishes it" % box,
           }[kind]
    print("  %s:%d %s: %s" % (path, n, why, text[:70]))
if len(bad) > 10:
    print("  ... and %d more" % (len(bad) - 10))
if bad:
    blocks = sum(1 for b in bad if b[2] == "executable block")
    print()
    if blocks:
        print("  an executable block must use $BOX_NAME; adm substitutes it")
        # No threshold on this hint, because even one is confusing when it is
        # a collision. A box named "ls" produces five, and five lines of
        # correct shell flagged as mistakes is worse than four hundred: the
        # operator has no reason to suspect the check rather than the code.
        print("  if these are ordinary shell rather than a hardcoded name,"
              " then")
        print("  BOX_NAME=%s collides with the text of these runbooks. No"
              % box)
        print("  pattern can tell those apart -- rename the box, or allow the")
        print("  lines in hosts/<box>/generic.allow.")
    if any(b[2] == "link target" for b in bad):
        print("  a link into hosts/<box>/ resolves on one machine only --"
              " drop the link")
    if any(b[2] == "forge hostname" for b in bad):
        print("  the forge host comes from $GIT_REMOTE; writing it into a"
              " file the")
        print("  public repo carries puts one installation's address in"
              " everyone's")
    sys.exit(1)
print("no generic file names %s or the forge host, and no link points"
      " inside hosts/<box>/" % box)
PY
verify: runbook-titles
cd "$ADM_ROOT"
# The contents list is built from each document's own `# ` heading rather than
# from its filename, so a heading that disagrees with the file it sits in now
# shows a wrong number to every reader. 050 read "006 - Boot into snapshots"
# for some time, and nothing noticed until the contents started quoting it.
rc=0
for f in runbooks/*.md; do
  n="$(basename "$f" | grep -oE '^[0-9]+')"
  h="$(grep -m1 '^# ' "$f")"
  hn="$(printf '%s' "$h" | grep -oE '^# [0-9]+' | grep -oE '[0-9]+')"
  if [ -z "$hn" ]; then
    echo "$f: first heading does not start with a number: $h"; rc=1
  elif [ "$n" != "$hn" ]; then
    echo "$f: filename says $n, heading says $hn"; rc=1
  fi
done
test "$rc" = 0 || exit 1
echo "every runbook heading agrees with its filename"
runs: export-publisher
sudo pacman -S --needed --noconfirm rclone
rclone version | head -1
verify: export-publisher
command -v rclone >/dev/null || { echo "rclone not on PATH"; exit 1; }
# Assert the two capabilities this design rests on rather than just the
# binary: an s3 backend, and link, which is what produces the pre-signed URL.
rclone help backends 2>/dev/null | grep -qw s3 \
  || { echo "this rclone has no s3 backend"; exit 1; }
rclone link --help >/dev/null 2>&1 \
  || { echo "this rclone cannot generate pre-signed links"; exit 1; }
echo "rclone $(rclone version | head -1 | awk '{print $2}') with s3 and link"
adm publish -n             # what it would do, without doing it
adm publish                # render, bundle, upload, sign the document only
adm publish --expire 1h    # a link for right now
adm publish --html-only    # the document alone, no repo bundle

adm publish renders before it uploads rather than sending whatever is in export/, which means a stale document cannot be published and the secret gate runs every time, before any bytes leave the box. It fails closed: a render that would contain a value encrypted in the env and absent from the source never becomes a file, let alone an object.

--s3-no-check-bucket is passed deliberately. Without it copyto attempts CreateBucket when the target is missing, which was observed on the first real run — a typo in PUBLISH_S3_BUCKET would have had this command provision a new Space and publish into it. Publishing is not a provisioning operation, and a missing bucket is an error rather than an invitation.

verify: export-publish-config
cd "$ADM_ROOT"
if ! adm publish -n >/dev/null 2>&1; then
  # Conditional on purpose: a box that has not been given Spaces credentials
  # is not misconfigured, and failing for want of them would be failing for
  # the wrong reason.
  echo "publishing not configured (PUBLISH_S3_* absent); nothing to check"
  exit 0
fi

out="$(adm publish -n)"
grep -q 'would upload: .*/index.html' <<<"$out" \
  || { echo "adm publish -n does not describe the document upload:"; echo "$out"; exit 1; }
# Both artifacts, because the bundle is the half that is easy to lose: it is
# skippable with --html-only, and a regression there would publish a readable
# document and quietly stop publishing anything you could clone.
grep -q "would upload: .*/$BOX_NAME.bundle" <<<"$out" \
  || { echo "adm publish -n does not describe the repo bundle:"; echo "$out"; exit 1; }
grep -q 'history is scanned' <<<"$out" \
  || { echo "the dry run does not mention the history scan"; exit 1; }
adm publish -n --html-only | grep -q "$BOX_NAME.bundle" \
  && { echo "--html-only still describes a bundle upload"; exit 1; }
grep -q 'RCLONE_CONFIG_PUB_SECRET_ACCESS_KEY' <<<"$out" \
  || { echo "the remote is not configured from the environment"; exit 1; }

# The dry run must name the variables and never their values. If this ever
# fails, a key is being printed to a terminal and into adm's run logs.
for k in PUBLISH_S3_ACCESS_KEY PUBLISH_S3_SECRET_KEY; do
  v="$(sops -d --input-type dotenv --output-type dotenv \
        "$ADM_BOX_DIR/$BOX_NAME.env" \
       | awk -F= -v k="$k" '$1==k {print substr($0, length(k)+2)}')"
  [[ -z "${v// /}" ]] && continue
  if grep -qF -- "$v" <<<"$out"; then
    echo "adm publish -n printed the value of $k"
    exit 1
  fi
done
echo "publish configured; the dry run names the remote and not the keys"