zap

convergent machine-state management: set up and ship to Linux machines from TypeScript ↯

repo

Introduction #

Zap sets up and ships to Linux machines you own, from a TypeScript file.

You have a few Linux machines, and each one has to be set up a particular way and kept that way as its needs change. With Zap you declare what should be true of a machine: these packages installed, this file with this content, this user with these SSH keys, this service running, this firewall. Zap reads the machine, shows you exactly what it would change, and changes nothing until you pass --wetrun. Run it again tomorrow and it touches only what drifted. The same tool then ships your app to that machine as a versioned release with a health check and rollback.

If you know Terraform, Zap brings its plan-then-apply discipline to the inside of a machine. If you know a virtual DOM, it works the same way: you describe the whole result, and it works out the smallest set of changes to get there. Unlike both, Zap keeps no stored copy of the machine: it diffs against the machine itself, read fresh on every run, so a change someone made by hand shows up in the plan.

Zap is a Rust binary that works over SSH or on the machine it runs on, with no agent on the host and no server. It evaluates your zap.ts with fgro, Gro's sandboxed JS runtime (install.md).

Who it's for

  • You run a few Linux machines by hand. A laptop, a VPS or two, a home server. You want them described in one place and set up the same way every time, without learning a fleet tool.
  • You ship your own services. A web app behind nginx with PostgreSQL on the box, deployed as release directories with an atomic swap, where the deploy refuses to ship onto a host whose config has drifted.
  • You let an assistant drive. An AI agent, or a teammate who isn't an ops engineer, runs the tool for you. Planning changes nothing, so they can do it freely; you review the plan, and a wetrun executes only that plan. Every run ends in an exit code that says what was established, and --json streams a wetrun as typed events rather than free-form logs.

What a run looks like

// zap.ts export default (zap) => { zap.target({local: true, package_manager: 'apt', service_manager: 'systemd'}); zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}}); zap.file({ id: 'gitconfig', path: '~/.gitconfig', content: {type: 'inline', content: '[user]\n\tname = Ada\n'}, }); };$ zap apply apply — target local (dry-run) user: ada home: /home/ada target: {"local":true,"package_manager":"apt","service_manager":"systemd"} [+] cache-update-apt (script) check: true > sudo apt-get update [+] gitconfig (file) path: ~/.gitconfig content: inline (19 bytes, sha512 0435b012ac1a81df…) [+] ripgrep (package) > sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ripgrep (dry-run — re-run with --wetrun to apply) plan_hash: d317a15e82f2f1117f0d9fa1fdafc079b70f9e3ac5e3103c4b3333f1b6afeab0 $ zap apply --wetrun apply — target local (wetrun) user: ada home: /home/ada target: {"local":true,"package_manager":"apt","service_manager":"systemd"} ok cache-update-apt ok gitconfig ok ripgrep applied: 3 ok, 0 failed, 0 skipped (of 3) plan_hash: d317a15e82f2f1117f0d9fa1fdafc079b70f9e3ac5e3103c4b3333f1b6afeab0

The cache-update-apt change is the package index refresh Zap puts in front of an install. The wetrun planned again, got the same plan_hash the dry run printed, and only then acted. Had the machine changed in between, it would have shown the new plan and refused it, or, on a terminal, asked before running it.

Point the target at a host instead and the same config converges a server over SSH. Getting started walks through both.

What Zap does differently

  1. A wetrun executes only the plan you reviewed. Dry run is the default, with no flag to auto-approve. --wetrun re-plans against the live machine, then refuses unless the plan matches the one you saw, by hash; on a terminal, when the plan isn't the reviewed one, it shows you the plan and asks first.
  2. Unknown means hold, not guess. A resource Zap can't read is left out of the plan and reported as undetermined, with exit 1. It never reads as "converged" because a probe failed. (A few kinds whose create step is idempotent read a failed probe as absent; see detection.md.)
  3. Every run reads the live machine. No detection cache, no stored copy of the machine. The security controls (firewall rules, SSH keys, sudoers, database auth) are read live every time, because they are what gets changed by hand.
  4. Apply checks its own work. After acting, Zap reads the machine again and fails the run if anything didn't converge.
  5. The config can only describe. zap.ts runs in a sandbox with no network, no writes, no environment, no clock, and emits a JSON declaration. Secrets are references, resolved by Zap when it acts and masked everywhere else.

Plus: a reload a failed run owed is remembered on the host and re-fired, and fetched content is pinned by hash in a lock file next to the config.

What Zap converges

  • Software: apt and apk packages, apt repositories, and from-source builds pinned to their source archive.
  • Files: files (inline, templated, copied, or fetched and pinned), directories, and symlinks.
  • Users: shell, home mode, SSH keys, sudoers, and groups.
  • Services: systemd and OpenRC, with restart and reload triggers.
  • PostgreSQL: databases and roles, with pg_hba rules.
  • System: swap files and firewalls (ufw, nftables).
  • Everything else: idempotent scripts, installers, assertions, and pipelines.

The full reference is resources.md.

Hosts: Debian/Ubuntu and Alpine. Zap manages real servers in both families, and the firewall, user, and database controls are read live on both.

What Zap is not

  • Not a cloud provisioner. No VPCs, load balancers, DNS, or managed databases, and no provider ecosystem. Zap converges the inside of machines you already have. Pair it with OpenTofu or Pulumi for the outside.
  • Not an orchestrator. No data-dependent branching mid-run. A script's shell can do anything, but Zap doesn't model it.
  • Not a fleet tool. One host per config, resources applied in order. Loop a shell over configs for a handful of hosts; reach for Ansible or pyinfra for hundreds.
  • Not an agent platform. Nothing runs on the host between runs.
  • Not a remover. Delete a resource from the config and the thing stays; Zap reports it as orphaned. Declare removals explicitly.
  • Not macOS or Windows. Zap manages Linux hosts only.

The full picture, against each alternative: comparison.md.

Status

Zap is pre-alpha and has one maintainer. It runs the servers of the project it comes from and the maintainer's own machines. The resource set is small and fixed, there is no plugin system, and the config schema will change before 1.0 without a compatibility layer. Read security.md before pointing it at a machine you care about, and faq.md for the questions that come up first.

Where next

Install #

Zap is built from source, and so is fgro, Gro's runtime, which evaluates your TypeScript config. Zap never runs a config without it.

What you need on your machine

ToolWhyCheck
Rust via rustup (it installs the toolchain pinned in rust-toolchain.toml)to build the zap binarycargo --version
C toolchain (cc; on Debian, build-essential)Rust links with itcc --version
fgro, Gro's runtimeevery command that reads a config evaluates zap.ts with fgro eval, in its kernel sandboxfgro --version
Linux with Landlock ABI 6 or later (6.12)the sandbox fgro evaluates inuname -r
sshremote targetsssh -V
scpa remote archive or a build source on a remote target
git, tarzap deploy
minisignonly for a build source with verify; on PATH, or named by ZAP_MINISIGN
Node and npmonly to import the shipped recipes and get editor typesnpm --version

Build

Zap depends on crates from the sibling fuz repository, resolved by relative path, so the two checkouts sit next to each other:

git clone https://github.com/fuzdev/fuz git clone https://github.com/fuzdev/zap cd zap cargo xtask install

cargo xtask install makes a release build and puts the binary at ~/.zap/bin/zap. Add that directory to your PATH:

export PATH="$HOME/.zap/bin:$PATH" # bash / zsh fish_add_path ~/.zap/bin # fish

Then:

$ zap version zap <version>

Debug and static musl builds are in contributing.md.

The binary on your PATH doesn't update when you pull. Run cargo xtask install again after pulling changes.

Getting fgro

Build fgro from a checkout of Gro, on x86_64 Linux. The build downloads and verifies a pinned V8 first, and the binary must carry its startup snapshot, which cargo xtask build embeds:

git clone https://github.com/fuzdev/gro cd gro cargo xtask fetch-v8 cargo xtask build --release

The binary is target/gro_snapshot/release/fgro. Don't point ZAP_FGRO at it there: each build replaces it, and a project's zap.lock pins the exact binary its configs are evaluated with (lock.md), so a rebuild would make every pin stale. Copy it to a place you update deliberately, and name that copy:

install -D -m 755 target/gro_snapshot/release/fgro ~/.zap/bin/fgro export ZAP_FGRO="$HOME/.zap/bin/fgro" # bash / zsh set -gx ZAP_FGRO ~/.zap/bin/fgro # fish

Zap takes the binary by absolute path, from --fgro <path> or ZAP_FGRO, and never looks it up on PATH. sudo resets the environment, so under sudo only --fgro reaches Zap: sudo zap apply --fgro ~/.zap/bin/fgro. Name the binary itself, not a link to it or a wrapper script.

Then pin it in each project: zap lock evaluates the config with that binary and records its blake3 in zap.lock; commit the lock. When you update the copy, run zap lock again in each project.

Zap and fgro talk through a versioned eval protocol. An fgro that answers in a version this zap doesn't read is refused, exit 2, whatever the pin says: update the older of the two, then zap lock.

Types and recipes for your config

A config needs a project of its own: a package.json beside it, or in a directory above it that you own and no other user can write. One holding only "type" or "sideEffects" doesn't count; a "name" is enough. A .js module the config imports needs "type": "module":

{"name": "infra", "private": true, "type": "module"}

A bare config needs nothing else installed:

export default (zap) => { zap.target({local: true}); // … };

For editor completion and type checking, import the builder type. The import is type-only, so the evaluator strips it and needs nothing resolvable at run time:

import type {ZapBuilder} from '@fuzdev/zap/zap.ts'; export default (zap: ZapBuilder) => { // … };

To use the shipped recipes (nginx sites, hardened systemd units, SSH hardening, certbot, firewalls), your project needs the @fuzdev/zap package resolvable under node_modules/@fuzdev/zap. The package isn't published to npm; point a path dependency at your checkout:

{ "devDependencies": { "@fuzdev/zap": "../zap" } }

npm install then symlinks the checkout into node_modules. That link, in the config's own project, is the one way its import of @fuzdev/zap resolves: a node_modules above the project is never searched (security.md). The checkout must not sit under a directory every user can write. fgro resolves recipe imports through the package's exports, which carry a gro condition pointing at the TypeScript sources, so a recipe edit in the checkout reaches your next plan with no build step. TypeScript and your editor resolve the same imports through the built dist/, so run npm install && npm run build in the zap checkout once (and after pulling) to keep types current.

import type {ZapBuilder} from '@fuzdev/zap/zap.ts'; import {ssh_hardening} from '@fuzdev/zap/recipe_ssh.ts';

See recipes.md for the catalog.

What a target needs

A local target needs sudo only if the config touches files you don't own or runs privileged commands. A remote target needs sshd, a POSIX shell with coreutils or BusyBox, and a sudo that works without a password for the user Zap connects as. Details per distro: targets.md.

Uninstall

Remove ~/.zap (the binary and, unless ZAP_HOME moved them, the caches, last-run reports, deploy previews, SSH sockets), and $ZAP_HOME if you set one; a zap run under sudo keeps its own, in the home sudo gives it (root's ~/.zap). Zap keeps no other state on your machine. On a target it leaves only what your config declared, plus any pending-trigger markers under /var/lib/zap/pending/, a build's <output>.zap-build provenance sidecar, an extracted remote file's .zap_remote_hash sentinel, and in a deploy's path, deploy_info.json and the .zap-deploy.lock file. A run killed outright can also leave a .zap.* staged file beside a file it was writing, or a release archive (.zap-deploy-<user>-<version>.tar.gz) in a deploy's path. A killed deploy can leave releases/.zap-stage and releases/.zap-old-* there too, which the next deploy to that path cleans up.

Getting started #

Two walks: your own machine first, then a server over SSH. Each takes a few minutes. Install Zap and fgro before you start.

1. Your own machine

Make a directory for the config, give it a package.json, which marks it as the config's project, and write a zap.ts that manages a directory and a file you own, so nothing needs sudo:

{"name": "infra", "private": true}// zap.ts export default (zap) => { zap.target({local: true}); zap.directory({id: 'notes', path: '~/notes', mode: '755'}); zap.file({ id: 'hello', path: '~/notes/hello.txt', content: {type: 'inline', content: 'hello from zap\n'}, depends_on: ['notes'], }); };

A config is a function. It receives a builder and declares a target and resources; it returns nothing. Every resource has an id you choose, which is how other resources and Zap's output refer to it.

Pin the evaluator. zap lock records the fgro build that evaluates the config in zap.lock; every other command that reads the config refuses until it does:

$ zap lock pinned fgro c8a3d0effbcb7ca4540fa9197dfff499c385547b10cff6931644d6450446dcb7 wrote zap.lock (no eligible resources)

Preview. zap apply without --wetrun reads the machine and shows what it would change. It changes nothing, and exits 2 to say changes are pending:

$ zap apply apply — target local (dry-run) user: ops home: /home/ops target: {"local":true} [+] notes (directory) path: ~/notes mode: 755 [+] hello (file) path: ~/notes/hello.txt content: inline (15 bytes, sha512 0c7463f032f1dc98…) (dry-run — re-run with --wetrun to apply) plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61 $ echo $? # $status in fish 2

[+] is a create, [~] an update, and the lines under each say what the change declares: a file's content shows as its length and the start of its SHA-512, never the bytes. The plan_hash names this exact plan. zap plan prints the same plan with a change count and exits 0: it reports, where apply judges.

Apply. Now act:

$ zap apply --wetrun apply — target local (wetrun) user: ops home: /home/ops target: {"local":true} ok notes ok hello applied: 2 ok, 0 failed, 0 skipped (of 2) plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61

After the changes ran, Zap read both resources again and found them converged, which is why the exit was 0. Had anything still read wrong, the report would say RESIDUAL and exit 1.

The wetrun went through because your preview a moment ago bound it: a wetrun executes only the plan you reviewed.

Had you run zap apply --wetrun first, with no preview behind it, it would have shown the plan and asked:

$ zap apply --wetrun apply — target local (wetrun, awaiting confirmation) … UNREVIEWED — this plan is not the one that was reviewed. no plan has been previewed on this host. plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61 Apply this plan? Type 5bb79cc9, the start of its plan_hash, to confirm: n apply — target local (wetrun, declined) … DECLINED — nothing ran: this plan was not confirmed, and is not the one that was reviewed. …

Typing 5bb79cc9 would have run exactly that plan: the answer is the start of a hash you can only know by looking at the plan. Declined, it changed nothing, exited 2, and became the preview: running it again executes if nothing moved. Without a terminal (a script, CI, < /dev/null) the wetrun doesn't ask; it renders the same plan under a REFUSED banner and stops the same way. --expect-plan <hash> binds explicitly. concepts.md has the rules.

Status. zap status tells you what the last run saw, offline:

$ zap status target local (pkg: none, service: none) last run on local: zap apply --wetrun, after its changes ran, at 2026-10-11T21:09:26Z — offline; `zap plan` reads the target live a 0 exit means every declared resource read converged in the last run and its config is unchanged since — not that the target still is converged notes directory converged hello file

Again. Preview once more:

$ zap apply apply — target local (dry-run) user: ops home: /home/ops target: {"local":true} (no changes — converged) plan_hash: 2d374e262633aebd2273acc69efc3122cff2bdbb6dc36f98d7d4bbc6cde5cb44

Now edit hello.txt by hand, add a line, and run zap plan --diff: the file reads [~] hello with ~ content: differs → inline (15 bytes). That is drift, and zap apply --wetrun would put the declared content back.

Add a package

Declare the package manager on the target and a package:

zap.target({local: true, package_manager: 'apt'}); zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});

zap apply now plans two changes: cache-update-apt, the index refresh Zap puts in front of an install, and the apt-get install -y ripgrep itself, both run under sudo on the wetrun. Packages are detected by existence: Zap asks dpkg-query whether ripgrep is installed, not which version. An entry written name=version pins a version instead (package names).

Add types

For completion in your editor, import the builder type (type-only, so the evaluator needs nothing installed):

import type {ZapBuilder} from '@fuzdev/zap/zap.ts'; export default (zap: ZapBuilder) => { // … };

See install.md for resolving the import.

2. A server over SSH

You need a Debian, Ubuntu, or Alpine host you can reach as a user who has password-less sudo. On a fresh Debian cloud image the stock user usually qualifies. On Alpine, as root:

setup-apkrepos -c -f apk update && apk add sudo adduser ops # skip if the account exists adduser ops wheel echo '%wheel ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel chmod 0440 /etc/sudoers.d/wheel

Install your key and check that plain ssh works silently:

ssh-copy-id ops@<host> ssh ops@<host> true

The config. It lives in a project of its own, with a package.json as before. The host and user are references: they come from your environment when Zap runs, never from the config file, so the config can be committed and reused. The user's variable is USER_NAME and not USER on purpose: your shell already sets USER, so a run that forgot the env file would connect as your local login name where an unset USER_NAME fails.

// zap.ts export default (zap) => { zap.target({ host: zap.env('HOST'), user: zap.env('USER_NAME'), home: '/home/ops', // that user's home on the host package_manager: 'apt', // 'apk' on Alpine service_manager: 'systemd', // 'openrc' on Alpine }); zap.pkg({id: 'chrony', from: {apt: 'chrony', apk: 'chrony'}}); zap.service({ id: 'chronyd', service: 'chrony', // 'chronyd' on Alpine enabled: true, running: true, depends_on: ['chrony'], }); zap.file({ id: 'probe', path: '/etc/zap-probe', content: {type: 'inline', content: 'managed by zap\n'}, mode: '644', }); };

The env file. Put the variables in a file Zap loads with --env-file. Keep it out of version control; commit a .example instead.

# zap.env HOST=203.0.113.10 USER_NAME=ops

Drive it.

zap lock --env-file zap.env # pin the evaluator zap detect --env-file zap.env # what the host looks like now zap apply --env-file zap.env # the plan zap apply --env-file zap.env --wetrun # converge zap apply --env-file zap.env # idempotency: (no changes — converged)

The dry run shows the apt-get update and the apt-get install, the systemctl enable and start, each with the exact command, and the file it will write. The wetrun reports 4 ok: the three resources plus the index refresh.

Edit the probe file on the host, or systemctl stop chrony, and zap plan shows the drift. That loop, plan then wetrun, is all of Zap.

What to commit

FileCommit?
zap.tsyes
zap.lockyes, it records the fgro build and any fetched bytes you approved
zap.env and friendsno; commit a .example
~/.zap/no, it's your machine's cache and last-run reports

Where to go next

  • A real server. The recipes carry the standard pieces: SSH hardening, a hardened systemd or OpenRC service, an nginx site with a CSP, a firewall, certbot, fail2ban, PostgreSQL. configs.md shows how to compose them.
  • Shipping an app. zap deploy releases a build to the host you just converged, with a health gate and rollback: deploy.md.
  • A host that locks itself down. When SSH hardening changes the user Zap connects as, declare both users and run the first apply with --bootstrap: targets.md.
  • What every command and exit code means. cli.md.

Concepts #

The ideas behind every zap command. Read this once; the other docs assume it.

The loop

describe → detect → plan → apply → observe

You describe a machine in zap.ts: these packages, this file with this content, this user with these keys, this service running. Zap detects what the machine looks like now, plans the difference, applies only that difference when you say so, and observes the result by reading the machine again. Every command is a slice of that loop.

A task fits Zap when it can be put as: I want the machine to look like X. What does it look like now? What needs to change? Do it. Tell me what happened.

Convergence

A resource is one typed piece of desired state with a stable id. Most resources are convergent: Zap reads their current state, compares it to the declaration, and changes only what differs. Re-running is always safe. A converged resource contributes nothing to the plan.

How much of the live state a declaration claims depends on the field:

A field that isMeansFor example
authoritativemade exactly the declared value; anything added by hand is removeda user's authorized_keys, a file's content
additiveeach declared entry must be present; an entry added by hand is neither detected nor removeda firewall's allow rules, a database's pg_hba rules, a user's groups
unsetunmanaged: Zap neither sets it nor reverts it, and a rewrite keeps ita file with no mode

One resource kind is imperative: a pipeline runs every time, as does zap deploy, which produces a new release every run. You make their steps safe to repeat.

Drift is the gap between the live machine and the declaration: a file edited on the host, a service stopped, a firewall's default policy flipped at the console. Zap doesn't track drift; it finds it by reading the machine on every run, and the plan is the repair.

What Zap doesn't do: it never removes something you stop declaring. Delete a package resource from the config and the package stays installed. zap status lists such resources as orphaned, as a warning. To remove something, declare the removal (a script whose check confirms it's gone).

Detection

For each resource, detection answers one question, does the desired state already exist?, with one of four verdicts:

VerdictMeaningThe planner
convergedthe desired state is presentskips it
driftedpresent but wrong; carries what was observedplans an update
absentnot presentplans a create
undeterminedcouldn't tell, with a reasonholds it out of the plan

When Zap can't read a resource (a probe failed, a remote path's home isn't declared, a tracked pin has no lock entry), it doesn't guess: the resource is held, the command leads with an UNDETERMINED banner naming it and why, and the exit code is 1 (deploy's preflight refuses with 2), never 0. Nothing carries over between passes; the next run reads it again.

The hold spreads to what can't run without the held resource: a change that depends on it (directly, or through resources that are converged) and a change that would restart or reload it are held behind it, and the banner lists them under it. detection.md

A few kinds on the bring-up path read a failed probe as absent instead, because on a fresh host the probe fails for the reason the config exists (ufw status fails before the same run installs ufw). Which kinds, and how database splits the two cases: detection.md.

Depth follows idempotency. If re-running a change is a true no-op, shallow detection is fine: a package is checked for existence (or, when its name pins a version, that version), because installing it again does nothing. If re-running has side effects, detection goes deeper: a file is compared by digest, mode, and owner; a user's keys and sudoers by content; a firewall rule by rule. A script's depth is yours: its check command is the whole idempotency contract.

Everything is read live, every time. Zap keeps no detection cache. A stored answer is only sound when nothing else could have changed the machine, and Zap never assumes it is the only writer. The security controls make the point: firewall, user, and database guard the host's access surface and are exactly what gets changed by hand.

Dry run and wetrun

No command changes the target unless you pass --wetrun. zap apply shows what would change and exits 2 when something would; zap apply --wetrun changes it. Most tools make the dry run the option. Zap makes it the default, and gives the flag that acts an unusual name so it isn't typed by accident or appended by a script's --yes habit. The default is safe with no terminal and no prompt, which is what makes Zap safe to hand to a script or an AI assistant.

The plan

A plan is the changeset for one run: per resource, the action (create or update), the steps, the desired state (its references kept as written), and the dependency order. It is content-addressed: plan_hash is the blake3 of the plan's canonical JSON and of the text Zap's own steps execute, printed at the end of every plan and apply.

Two things with different stability go into it. The declaration is a function of your config and the files it reads: the same zap.ts over the same files evaluates to the same desired state, because the config runs in a sandbox with no network and no environment, with the clock and randomness closed and the time zone and locale fixed (configs.md). The changeset also depends on what detection found. So the hash addresses "what this run decided to do against this target."

The plan is for one machine. Its header names the target it resolves to; a local plan also names the user Zap runs as and the HOME a ~ resolves to:

$ zap plan plan — target local (2 change(s): +2 ~0) user: ops home: /home/ops target: {"local":true} [+] notes (directory) path: ~/notes mode: 755 [+] hello (file) path: ~/notes/hello.txt content: inline (15 bytes, sha512 0c7463f032f1dc98…) plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61

A remote plan prints the connecting user in a user: line, and the host in a host: line when the declared host is a reference. All of it is in the hash, so one config under two env files is two plans, and so is the same host with and without --bootstrap. A local plan's hash covers the user Zap runs as and its HOME, so a review made as you binds no local wetrun under sudo, which runs as root, or under another HOME.

The plan is for one environment too. A reference in a resource stays written as its reference ({env:NAME}), and the plan records beside it what each reference its changes use resolves to, printed in a references: block under the target: the value of a plain one, and for a secret only that it is one. So plan_hash covers those values: the same config under another value of APP_DIR is another plan. A secret reference's value is never in the plan or its hash, so rotating one changes neither.

A wetrun executes only the plan you reviewed

A wetrun doesn't take a saved plan as input; it detects and plans again, because the machine may have moved. What it guarantees instead is that the plan it executes is the plan you saw. Before it changes anything, it compares its fresh plan_hash against the plan you reviewed:

  • --expect-plan <hash> names the reviewed plan explicitly, and the wetrun executes only if the fresh plan has exactly that hash;
  • with no flag, the reviewed plan is the one in this host's last-run report, when the pass that wrote it was a preview: zap plan, a dry zap apply, or a wetrun that refused. Its recorded plan_hash must equal the fresh one.

Otherwise the wetrun refuses. It changes nothing, renders the plan it would have run under a REFUSED banner that says why (no preview yet, the last run wasn't a preview, or the plan changed since the preview), records that render as the new preview, and exits 2. The everyday flow is zap apply, read it, zap apply --wetrun. A wetrun fired cold becomes the preview, and the next one executes if nothing moved. A preview covers one wetrun: one that executes, whether its changes succeed or fail, spends it, and of two started on one preview only one goes on. An agent given --expect-plan with the hash it showed you can execute only that plan: the same changes, on the same machine, as the same user, with the same values for every reference that isn't a secret. An empty plan needs no review.

On an interactive terminal, a wetrun whose plan has no review asks for the first eight characters of its plan_hash instead of refusing (cli.md).

A plan can only be reviewed if it names its machine. A plan whose host or connecting user resolves through a reference Zap masks has no plan_hash: zap plan and a dry zap apply show it under an UNBINDABLE note, and zap apply --wetrun is an error before anything runs, even when nothing would change. The ways out are in env.md.

zap deploy has its own plan, hash, and recorded preview, and its wetrun follows the same rule against them (deploy.md).

Apply asserts convergence

A wetrun doesn't stop at command success. After the changes run, it reads every resource again. Any resource that still doesn't read converged is reported as RESIDUAL and the run exits 1: a script whose check still fails after its run, a mutation that read back wrong. A change that failed or was skipped is the run's failure already, a held resource its undetermined, and a pipeline never reads converged (it always runs), so none of them is residual. "Applied: N ok" alone would be a claim about commands; the re-detection makes it a claim about the machine. If the re-detection itself fails, the run says so and exits 1 too, because "converged" was not established.

The last-run report

Every detection pass writes a last-run report under ~/.zap, one per config and host, latest only (none for a host that resolves through a masked reference, since it would stand for every machine the reference names): when it ran, which command, the host, each resource's verdict and a hash of its declaration, the names of the references it couldn't resolve, the batches it ran, and the plan_hash when the pass planned. Nothing the target returned is in it, and no secret's value reaches any hash (env.md).

Detection never reads it. zap status renders it offline, marking each resource whose config changed since the run, and it holds the preview a wetrun binds to. Exit codes are about the last run, not the live machine; the live answer is always zap plan.

Triggers

Service detection reads running and enabled, not whether the service's config is fresh. Triggers close that gap: a file names the services it triggers, or a service names the resources it restart_on / reload_on. When a source changes in the current plan, the service is promoted from skip to restart or reload. A trigger restarts or reloads only a running service: a stopped one is left stopped and starts on the new state when it starts. A trigger on a service declared running: false is a config error.

A trigger fires only on a change in the current plan, so a failed reload would be lost: next run the file is converged and the service is running, on the old config. Zap keeps one marker per owed service on the target under /var/lib/zap/pending/, written before the first source changes and cleared after the service's change completes. A failed reload stays owed and re-fires on the next plan. At least once, never zero.

A held service, whether detection can't read it or it is held behind another, holds every source whose change would trigger it: run alone, the source would owe a restart that no change in the plan performs. Both wait for a plan that can run the service.

The lock file

A moving reference makes intent ambiguous in time: a tracked remote file's URL or a build's source archive can serve different bytes tomorrow. zap.lock, next to zap.ts and committed with it, pins each tracked reference to the blake3 of its resolved bytes. Detection compares the target against that pin, and only an explicit act moves it: zap update (advance one or all), zap lock (rebuild), or a wetrun recording a pin that was missing. A pin that isn't current is withheld from the read-only commands, so the resource holds rather than being compared against stale bytes. Full detail: lock.md.

Targets and privilege

A config has one target: your own machine (local: true) or a host over SSH. Your authority on a remote host is SSH: Zap connects as the user you declare, with your keys, and escalates with the host's sudo. Commands that are privileged by nature (apt-get, systemctl, ufw) carry sudo. On a local target file operations try natively first and escalate only when denied; over SSH they run under sudo. There is no agent, no server, and no shared secret. See targets.md.

Secrets and references

A config never reads the environment. Anything that varies per machine or is secret is written as a reference, which the plan's steps keep as written and Zap resolves when it acts; the plan records what each plain one resolves to, so a review covers it. A secret's value is masked out of every output, event, and report. A secret written literally into a config is rejected when Zap can tell (its value is referenced and set in the environment). See env.md.

Deploy

zap deploy is the one imperative command: it ships a versioned release to a remote host as releases/<version> plus a current symlink, restarts the service, polls health, and rolls back if the health gate fails. Before shipping, its preflight runs the convergence pass and refuses to ship onto a host that has drifted from its config. The same tool that built the machine ships the app. See deploy.md.

Writing configs #

How a zap.ts is shaped, what it may and may not do, and the patterns that keep one readable. The per-resource fields are in resources.md.

The shape

A config is a module whose default export is a function. It receives a builder, declares one target plus any number of resources, and returns nothing:

import type {ZapBuilder} from '@fuzdev/zap/zap.ts'; export default (zap: ZapBuilder) => { zap.target({host: zap.env('HOST'), user: 'ops', home: '/home/ops', package_manager: 'apt', service_manager: 'systemd'}); zap.pkg({id: 'nginx', from: {apt: 'nginx'}}); zap.file({ id: 'site', path: '/etc/nginx/sites-available/example', content: {type: 'inline', content: site_config}, validate: 'sudo nginx -t', triggers: ['nginx-service'], depends_on: ['nginx'], }); zap.service({id: 'nginx-service', service: 'nginx', enabled: true, running: true, depends_on: ['nginx']}); };

The builder has one method per resource kind (pkg, apt_repo, script, installer, user, directory, file, link, service, database, swap, firewall, verify, pipeline, build), plus target, deploy, mask, unmask, and env (which returns a reference). Every other call returns the builder, so chaining works, and every resource takes these shared fields:

FieldRequiredMeaning
idyesa stable name, unique in the config: a letter, a digit, or _, then any of those and . + : @ -. Output, events, triggers, and depends_on all refer to it. Renaming an id makes Zap see a new resource.
namenoa label for whoever reads the config. A plan prints it under the change's header; everything else (apply's progress, events, triggers) names a resource by its id. It is carried in a change, so changing it changes the plan_hash of a plan that changes the resource, but never the lock entry's config_hash.
depends_onnoids that must apply before this one

The import is type-only. The evaluator strips it, so a config with no recipe imports needs nothing installed. Without it, write (zap) => and lose completion.

The function may be async: Zap waits for it, so a declaration after an await counts. Once it returns, the builder closes, and a call that arrives later (from a promise the config didn't await) fails the evaluation, even when the config catches the error, so a forgotten await can't drop a resource silently.

Plain TypeScript is the templating language

There is no DSL for loops, conditionals, or includes; the language has them.

const PACKAGES = ['build-essential', 'pkg-config', 'libssl-dev']; for (const pkg of PACKAGES) { zap.pkg({id: pkg, from: {apt: pkg}}); } const USER = 'app'; const HOME = `/home/${USER}`; zap.user({id: 'app-user', username: USER, ssh_keys: {copy_from: 'root'}, sudo: false}); zap.directory({id: 'app-dir', path: `${HOME}/app`, owner: USER, group: USER, depends_on: ['app-user']});

Keep every value two resources must agree on (a user name, a config directory, a port) in one constant. Nothing fails when two copies drift apart.

Split a large config into local modules and import them by relative path from the project. The shipped recipes are the same idea packaged: pure functions that return a string or a spreadable payload.

import {SSHD_VALIDATE, ssh_hardening, ssh_hardening_verify} from '@fuzdev/zap/recipe_ssh.ts'; import {debian_ssh_service} from '@fuzdev/zap/recipe_debian_ssh.ts'; const HARDENING = {user: USER}; zap.file({ id: 'sshd-hardening', path: '/etc/ssh/sshd_config.d/00-hardened.conf', content: {type: 'inline', content: ssh_hardening(HARDENING)}, mode: '644', validate: SSHD_VALIDATE, }); zap.service({id: 'sshd', ...debian_ssh_service({reload_on: ['sshd-hardening']}), depends_on: ['sshd-hardening']}); zap.verify({id: 'sshd-hardened', ...ssh_hardening_verify(HARDENING), depends_on: ['sshd']});

One target per config

A config declares one target. For several hosts, write one config per host (or one module per role, imported by each host's config) and run zap once per host. A zap.env('HOST') reference plus one env file per host is the usual shape. A second zap.target call, or a second zap.deploy, fails the evaluation.

References

A config never reads the environment. Anything that varies per machine or is secret is a reference, zap.env('NAME'), left inert in the declaration and resolved by Zap when it acts, from your environment or an --env-file:

zap.target({host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER')}); zap.file({id: 'env', path: '/srv/app/shared/.env', content: {type: 'template', template: 'DATABASE_URL={{url}}\n', vars: {url: zap.env('DATABASE_URL')}}});

Not every field takes a reference, a wetrun refuses one that is unset or empty, and one Zap reads as a secret is masked out of every output; the rules are in env.md.

~ and $HOME in a path are different: they resolve against the target's home (declared home for a remote target, your own locally), never the operator's. A remote path that uses them with no home declared is undetermined.

mask and unmask

Zap treats a reference as a secret by where it is used (an auth credential), by its name (API_TOKEN, DB_PASS), or by the form of its value (a URL with a password, a private key); the rule is in env.md. Say so where it reads one wrongly:

zap.mask('LICENSE_CODE'); // a secret the rule doesn't recognize zap.unmask('SSH_KEY_COMMENT'); // not a secret despite the name

Both take variable names, not references: zap.mask(zap.env('X')) fails the evaluation, and so does a name given to both.

Order

Resources apply in dependency order: depends_on edges first, and among resources with no edge between them, alphabetically by id, not in declaration order. Declaration order is not a dependency. When one resource must precede another, say so with depends_on; the structural gate rejects a dangling id or a cycle. A trigger is an edge too: a service runs after each resource it restart_on / reload_on and each file that triggers it, and a cycle through those edges is rejected the same way.

Detection for the whole run happens before any change executes. So a probe can't see a tool the same run installs; the detectors on the bring-up path read that failure as "not set up yet" rather than holding (detection.md), and the run both installs and configures. concepts.md has the rule.

An edge is also how a hold travels: a resource detection can't read is held, and every change that depends on it is held behind it rather than run ahead of it (detection.md).

Triggers

A file lists the services it triggers; a service lists the ids it restart_on or reload_on. Either direction works; pick one per pair. When the source changes in a plan, the service is promoted to a restart or a reload. A reload the run couldn't complete is remembered on the host and re-fired next plan (concepts.md).

zap.file({id: 'unit', path: '/etc/systemd/system/app.service', content: {…}, triggers: ['app']}); zap.service({id: 'app', service: 'app', enabled: true, restart_on: ['env-file']});

The sandbox

Your config runs in fgro eval, Gro's runtime, with no network, no writes, and no environment: it can compute and import, and that is all. These rules shape how you write one:

  • It lives in a project: the nearest directory at or above it whose package.json holds more than "type" and "sideEffects". The project can't be your home directory or a directory holding it.
  • Its imports are static, from that project and the packages it links, and must all resolve: import() is refused, and so are node: built-ins.
  • Resolution is strict ESM: a relative import names its extension, and a bare import goes through the package's exports (never its main) under the conditions gro, import, and default. No deno.json or import map is read.

Zap refuses a config that breaks one, exit 2, saying what to change. The walls in full: security.md.

The config's only output is a JSON declaration, which every command validates against a strict schema: an unknown field, a wrong enum value, a value of the wrong JSON type (an array where an object goes, a number where a name goes), or a contradictory combination (a local target with a host, sudo: true on a user) fails with a message that names it.

What a config declares is plain data: strings, finite numbers, booleans, arrays, and plain objects. A NaN, a function, a Map, a Date, a class instance inside a declaration, an undefined array item, or a value that holds itself fails the evaluation with the field's name, where JSON.stringify would have dropped it or written something else. A field is left out by leaving it out, or by giving it undefined (so mode: opts.mode forwards an option that may not be set). A null is an error that names the field, since a null where a mode, a sudo, or a blake3 pin goes would otherwise read as "not managed". The one place a null is data is inside a template variable's value.

Zap also closes what a config could read by mistake and get a different answer from on another run or another machine: the clock, randomness, facts of the machine, and timers. Each throws, naming itself and what to write instead, or isn't there. The time zone and locale are fixed rather than closed: every evaluation runs in UTC, and its default locale is en-US. A config reads no file but the modules it imports, so the same files declare the same thing on every machine.

Consequences worth knowing:

  • The clock and randomness throw: Date.now(), new Date(), performance.now(), Temporal.Now.instant(), Math.random(). There is no crypto, no setTimeout, and no Blob or File. Write the value as a literal, or reference it.
  • Local time is UTC: new Date(2026, 0, 1) and getHours() read the same as Date.UTC(2026, 0, 1) and getUTCHours() on every machine.
  • Comparing and formatting with no locale given use en-US: (1234.5).toLocaleString() is 1,234.5.
  • There is no process, no Deno, and no environment API. Use zap.env('NAME'). A guarded read such as globalThis.process?.env?.HOME is undefined, not an error, so a config that falls back on it always takes the fallback.
  • What a config prints is not part of the declaration: console writes to stderr, which Zap shows when the evaluation fails. console.time, console.timeLog and console.timeEnd print nothing.
  • The evaluation is jitless, so WebAssembly is absent; separately, code generation from strings is off, so eval() and new Function() throw.
  • WeakRef and FinalizationRegistry are inert: a weak reference never clears, and a cleanup callback never runs.
  • fetch and remote import fail. Vendor what you need or import it from the project.
  • A script's run is shell that executes on the target at apply time. The sandbox bounds the config, not the commands it declares. Review them.

What the gates reject

Every command that reads a config runs two machine-free gates before touching anything, and zap config validate runs only them:

  • Secrets. A literal that matches a resolvable secret is rejected; reference it with zap.env('NAME').
  • Structure. Each of these is rejected:
    • a NUL byte in a string a shell or path would see;
    • a duplicate id, or an id that isn't a name;
    • a blank string where a name, a path, a URL, or a command goes, and a line break or other control character in a field that is one line (a command, a validate included, a file's content and template variables, an env value, and a verify assertion's expect may span lines);
    • a path that must be absolute and isn't, and a path, link target, host, or user that begins with -;
    • a remote target with no host;
    • two resources that manage one thing: the same path, account, service, or database, or a second firewall (resources.md);
    • a template whose placeholders and variables don't match (resources.md);
    • a URL Zap or the target would fetch that isn't https:// (resources.md);
    • a database name, role, or auth.hosts entry that pg_hba.conf would read as something else;
    • a depends_on, triggers, restart_on, or reload_on naming an id that doesn't exist, a triggers naming a resource that isn't a service, or a dependency cycle, trigger edges included;
    • a user whose ssh_keys.keys holds no key;
    • pipeline features the executor doesn't run (env, a max_concurrency above 1, a step's cwd, env, or depends_on);
    • a named firewall profile under nftables, or an nftables firewall with no enabled nftables service in the same config;
    • a service, a file with triggers, or deploy.service on a target with no service_manager;
    • a validate command that is blank or on remote content;
    • a script cwd or a deploy.path that isn't an absolute path;
    • a deploy.shared entry that isn't a plain relative name, or a deploy.artifacts entry beginning with - or @ (deploy.md);
    • an env name beginning with __zap_;
    • a deploy.releases below 2;
    • a blank deploy.health.domain.

Two kinds of mistake fail earlier, when Zap reads the config, so the gates never see them: a value of the wrong form (a package or service name that isn't one word, a mode above 7777, a port or a count of 0), and a reference in a field that takes none (env.md). An object key $env or $text anywhere in a declaration is reserved and fails the evaluation.

Run zap config validate in CI and before committing; it needs no target, no network, and no secrets.

Checklist for a new config

  1. zap config validate passes.
  2. zap plan --env-file … reads every resource (no UNDETERMINED, no UNVERIFIABLE).
  3. Every script has a check precise enough that a converged host plans no change. A check that always fails makes the script run on every apply and reads RESIDUAL after it.
  4. Services that depend on files declare the trigger, and the files that must be valid before a reload declare validate.
  5. A second zap apply after the wetrun prints (no changes — converged).
  6. zap.ts and zap.lock are committed and the env files aren't (getting-started.md).

Deploy #

zap deploy ships a versioned release of your application to a remote host over SSH and swaps it in atomically, with a health gate and automatic rollback. The host is one your config already converges with zap apply: the same tool that built the machine ships the app, and the deploy refuses to ship onto a host that has drifted from its config.

Deploy is the one imperative command. Each run produces a new release, so there is nothing to converge to. It still plans before it acts: a dry run prints every step with the command, script, or URL behind it, and a wetrun executes only the plan a dry run showed.

Configuration

export default (zap) => { zap.target({ host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER'), package_manager: 'apt', service_manager: 'systemd', home: '/home/app', }); zap.deploy({ build: 'gro build', artifacts: ['build/', 'dist_artifacts/'], path: '/home/app/app', releases: 5, service: 'app', shared: ['.env.production'], prepare: ['deno cache server.js'], health: {domain: zap.env('DEPLOY_DOMAIN'), path: '/health', attempts: 15, interval_ms: 2000}, }); };
FieldRequiredMeaning
buildnocommand run locally before packaging; omit it, or pass --skip-build, and the plan has no build step
artifactsyesfiles and directories, relative to the directory you run zap from, packaged into the release. An entry can't begin with - (tar would read it as an option; write ./-name) or with @ (which some tars read as an archive to include)
pathyesthe remote app root: holds releases/, shared/, and the current symlink. An absolute path, written out (no reference, no ~); it must exist and be writable by the connecting user, which zap apply usually arranges
releasesnohow many releases to keep, the live one included (default 5). At least 2: the release just deployed and the one it replaced are always kept, since a failed deploy rolls back to the second
servicenothe service restarted after the swap; requires target.service_manager; takes no reference
sharednofiles under <path>/shared/ symlinked into each release root. Each entry is a relative name of a file: no .. component, no control character, not ending in / or in a . component (config/.); anything else is rejected by the structural gate. Each must exist (zap apply usually writes it): the deploy fails before extracting otherwise
preparenocommands run on the target in the new release, as the connecting user, before the swap, each in a shell of its own; the release is still under a hidden staging name then, so a command shouldn't record its absolute path
healthnothe post-restart health gate, below

health

FieldRequiredMeaning
domainyesthe host polled over HTTPS; usually a zap.env('DEPLOY_DOMAIN') reference
pathyesthe request path, beginning with / (/health, or /ready to make it the gate); takes no reference
attemptsnopolls before the deploy fails (default 15), at least 1
interval_msnodelay between polls in milliseconds (default 2000)

Omit the block to deploy with no health gate and no readiness check; there is no flag to disarm a declared one.

Commands

zap deploy # dry run: the whole plan and its plan_hash, recorded as the preview zap deploy --wetrun # ship the previewed plan: build, upload, swap, restart, health-check zap deploy --wetrun --expect-plan <hash> # ship only the plan with this hash zap deploy --wetrun --skip-build # no build step: ship the local artifacts as they are zap deploy --wetrun --skip-preflight # ship without the drift preflight zap deploy --wetrun --force # ship an unchanged version, or a dirty tree zap deploy --wetrun --json # stream step events as JSON lines

A wetrun binds to a preview made with the same plan-changing flags (below). --env-file <path> loads the operator env first, as for every command. Flags, exit codes, and what a dry run reads: cli.md.

What it needs. On your machine: git, tar with gzip, and ssh, with zap run from inside the application's git checkout. On the target: what a file write needs (targets.md), plus tar, id, and flock.

First deploy

A first deploy onto a fresh host is a chicken-and-egg: the binary lands via deploy, but deploy's preflight requires a converged apply, and a service declared running: true can't be running before there is anything to run. Resolve it in the config: declare the service enabled and leave running unset.

zap.service({ id: 'app-service', service: 'app', enabled: true, // no `running`: zap manages enablement, not runtime state, so the service // doesn't drift before a binary exists; the deploy's restart starts it restart_on: ['app-unit'], });

Then the bring-up is two reviewed runs: zap apply, then zap apply --wetrun, provisions the host (firewall included, so deploying first would bail), and zap deploy, then zap deploy --wetrun, ships the first release, whose restart step starts the service. Preview the deploy after the apply has run: the dry run connects as the user the apply sets up, and a pin the apply writes to zap.lock is part of the deploy plan.

The trade is explicit: a config that leaves running unset tells Zap never to assert that the app is up, so zap apply on a host whose daemon crashed and stayed down reports converged. Declaring running: true buys that assertion back at the cost of one deploy --wetrun --skip-preflight on the very first release (previewed with deploy --skip-preflight, since the flag is part of the plan), and --skip-preflight disables the whole drift gate for that run.

When SSH hardening changes the user Zap connects as, add --bootstrap to the first apply: targets.md.

Binding a wetrun to a reviewed plan

A dry run prints the whole plan and ends with its hash:

deploy — target {env:DEPLOY_HOST} (dry-run) version: 3f2a9c1e7b04 (previous: 8d41e0a6c2f9) host: app.example.com target: {"local":false,"host":{"$env":"DEPLOY_HOST"},"user":{"$env":"DEPLOY_USER"},"home":"/home/app","package_manager":"apt","service_manager":"systemd"} references: DEPLOY_DOMAIN = app.example.com tree: clean preflight: no apply-managed resources declared — nothing to check - Build application (build) local$ gro build - Create tarball (tarball) local$ tar -czf "$ZAP_DEPLOY_ARCHIVE" -- 'build/' 'dist_artifacts/' writes $ZAP_DEPLOY_ARCHIVE: a file in a private directory of this run, removed when it ends - Upload tarball (upload) the archive → target:/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz (as the connecting user, mode 600, checked against its SHA-512) - Extract and swap release (extract_swap) target script (its stdin: the archive's SHA-512): set -e zap_archive='/home/app/app/.zap-deploy-app-3f2a9c1e7b04.tar.gz' … - Restart app (restart) target$ sudo systemctl daemon-reload && sudo systemctl restart app - Health check (health) GET https://{env:DEPLOY_DOMAIN}/health — healthy on 2xx; up to 15 attempt(s), 2000 ms apart polls https://app.example.com/health - Readiness check (readiness) GET https://{env:DEPLOY_DOMAIN}/ready — ready on 2xx, unserved on 404; up to 15 attempt(s), 2000 ms apart polls https://app.example.com/ready (dry-run — re-run with --wetrun to deploy) plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312

A wetrun executes only a plan you were shown. It computes its plan again, and that plan's hash must equal the reviewed one:

  • --expect-plan <hash> names the reviewed plan explicitly, and binds alone;
  • with no flag, the reviewed plan is the deploy preview recorded for this config and host: the last zap deploy dry run, or a wetrun that refused.

Otherwise the wetrun refuses before the drift preflight and any step, prints the plan it would have run under a REFUSED banner that says why (no preview yet, the plan changed since the preview, or the hash doesn't match), records that render as the new preview, and exits 2. Nothing is built or uploaded. The everyday flow is zap deploy, read it, zap deploy --wetrun with the same flags. A wetrun fired cold becomes the preview, and the next one executes if nothing moved. There is no flag that skips the review, and a dry run rejects --expect-plan.

On an interactive terminal (standard input and standard error both terminals), a wetrun with no review and no --expect-plan shows the plan instead, under an UNREVIEWED banner with its plan_hash, and ends with the question:

UNREVIEWED — this plan is not the one that was reviewed. no deploy plan has been previewed for this host. plan_hash: 1f98612fcbfacb69b9d31236457d549e00714e4ad0fb0a36e225c3fe6a5f4312 Deploy this plan? Type 1f98612f, the start of its plan_hash, to confirm:

Typing those eight characters back is the review: the wetrun goes on with exactly the plan shown, to the drift preflight, the withdrawal of any recorded preview, and the steps, as a bound wetrun does. Any other answer is the refusal above, under a DECLINED banner. The prompt follows zap apply's rules: no flag answers it, --json never asks, and a script stays non-interactive by previewing first, passing --expect-plan, or running with standard input that isn't a terminal (cli.md).

One recorded preview covers one execution: a wetrun that passes the review and the preflight withdraws it before its first step, so a deploy that fails needs a new preview (or --expect-plan) before it runs again, and of two wetruns started on one preview only one goes on. An --expect-plan hash is not single-use. A preflight bail or an already-deployed exit leaves the preview in place. A preview doesn't expire.

The preview is a small file under ~/.zap (lock.md), separate from the last-run report that zap apply binds to: a zap plan doesn't disturb a deploy preview, and a deploy dry run doesn't disturb a convergence one. It holds the hash, not the plan. A deploy wetrun's drift preflight does replace the convergence record: it records its pass as the last-run report, which is no preview, so a zap apply previewed before it needs a new preview before its wetrun. With no state directory nothing is recorded, the run says so, and only --expect-plan binds.

Everything the hash covers is printed but two of Zap's own scripts: the one the upload installs the archive with, and the swap the auto-rollback runs. So a plan reviewed under one zap build doesn't bind one whose scripts differ. It covers:

  • the version, the tree state, and the previous release on the target;
  • the declared target, the host it resolves to, and the connecting user (in the uploaded archive's name);
  • who is deploying (user@host of the operator machine, recorded in deploy_info.json);
  • the preflight's state: skipped, nothing declared, or which apply-managed resources it checks, as a count and a digest of their declarations and the current pins zap.lock holds for them;
  • every step: the build and tarball commands, the upload paths and the script that installs the archive, the remote script with its shared links and prepare commands, the restart command, the health and readiness URLs as they resolve, and the swap the auto-rollback would run;
  • the value of each reference the deploy block uses (its health domain), printed in its references: block (a secret's value excepted: the plan records only that it is one).

So a plan reviewed for one host, tree, flag set, or operator does not bind a wetrun for another: a new commit, a deploy or rollback by someone else in between, an edit to any declared resource, a changed pin in zap.lock, --force on a dirty tree, --skip-build, --skip-preflight, or --bootstrap each make a different plan. The hash is printed, and the preview recorded, only when the steps are shown: an already-deployed dry run does neither, so a redeploy binds to the plan a --force dry run shows.

It does not cover:

  • the artifact bytes. The version names the commit, and the plan says whether the tree was dirty, but what the build produces is not hashed. The review covers the script that verifies the archive it was sent, not the archive.
  • the values a resource's references resolve to. The digest takes each declaration as written, so it is the same in every environment; the environment reaches the hash only through the host, the connecting user, and the health domain. The same holds for the bytes of a local source file.

A masked host, connecting user, or health domain can't be deployed to. No review can bind such a plan: a wetrun is an error that names the reference before anything runs, and a dry run shows the plan under an UNBINDABLE note, with no plan_hash and no recorded preview. The ways out: env.md.

Text from the config, the environment, and the target is printed with control characters written out as <U+XXXX>, so a value can't hide or forge a line of the preview (security.md).

Preflight

Before deploy touches the network, the machine-free gates run (secrets, structure, and, for a deploy config, a present health.domain), and a wetrun rejects any unset reference and a host, connecting user, or health domain behind a masked reference. zap.lock is read here too, when the drift preflight will check. --skip-preflight doesn't skip the gates.

Then a wetrun checks its review and runs the drift preflight: the convergence pass against the config, before building or uploading. If apply-managed state (an nginx site, the env file, the service unit) was edited in the config but never applied, shipping a new artifact against it would be a silent mismatch; the preflight bails with exit 2, and a change held behind a resource it couldn't read counts as drift. A pipeline, which detects absent and runs on every apply, is not drift, and neither is a service that changes only because its trigger fires, planned or held: it reads converged, no pending restart or reload is recorded for it, and every source that fires it is itself not drift (a pipeline, or another such service). A service that also drifted, one owed an action an earlier apply never completed, or one fired by any other change is drift. It also bails, with the same code, when any resource is undetermined: a gate that couldn't check everything doesn't pass by default.

After a drift bail, review the changes with zap apply, converge with zap apply --wetrun, and re-run the deploy: its review still binds unless zap.lock changed.

--skip-preflight skips the drift preflight, and a config that declares no apply-managed resources has nothing to check. Either way the plan says so on its preflight: line.

The deploy also refuses when the live release already has this version, on a dry run too (server already at this version, exit 0). --force ships anyway. A current that names this version but whose release isn't on the host serves nothing, so it isn't refused: the run plans a redeploy and warns that current names a release that is not on the host.

Dirty trees

A tree with uncommitted changes or untracked files git doesn't ignore deploys only under --force, as <hash>-dirty-<id>. Without it, a wetrun is refused before the target is contacted (exit 2), since the release would ship the tree under the commit's version; a dry run shows the plan (tree: uncommitted changes or untracked files) with no hash, records nothing, and says the wetrun is refused, even when the commit is the live release.

The id is 8 lowercase hex of a blake3 over what the tree adds to the commit, so the same tree gets the same id (a dry run and its wetrun agree) and a second dirty tree on one commit is a release of its own:

In the treeCounts by
tracked and staged changesgit diff HEAD --binary
an untracked file git doesn't ignoreits name and contents
an untracked symlinkthe path it points to, as git records one, whether that is a file, a directory, or nothing
an untracked nested repositoryits name alone
a submoduleits commit and whether its own index shows changes, not what they are
a sparse checkoutthe files outside its cone, as deletions: the tree reads dirty

Zap reads the tree against a copy of the index's entries that holds no timestamps or flags, so every file is compared by its contents: a file the index marks unchanged (assume-unchanged, skip-worktree) counts like any other, and so does one whose timestamps the index's cache would take for unchanged. Each read sets core.fsmonitor=false, core.untrackedCache=false, core.ignoreStat=false, core.fileMode=true (a mode change counts), core.autocrlf=false (line endings are not converted), and core.splitIndex=false, and passes --untracked-files=normal and --ignore-submodules=none, so status.showUntrackedFiles, a submodule's ignore setting, and diff.ignoreSubmodules don't narrow it either. So a checkout on a filesystem without executable bits, or one checked out with core.autocrlf=true, reads dirty, and deploys only under --force.

Two things still decide what counts as a change. The checkout's attributes (.gitattributes, .git/info/attributes) are applied: a clean filter, LFS among them, or an eol or text attribute can make a file that differs from the commit compare equal to it, and no flag turns them off. A submodule's own index is read as it is, so an assume-unchanged edit or stale timestamps inside one can read clean.

A sparse checkout reads dirty and deploys only under --force: the files outside its cone aren't there, so the tree the build sees is not the commit's.

A tree Zap can't read is an error that says what git said, not a clean tree.

The release mechanism

Once the preflight passes, a wetrun runs these steps in order:

build (local) → tarball → upload → extract + swap → restart → health → readiness
  1. build: run deploy.build locally (gro build, cargo build --release, whatever produces your artifacts). With --skip-build, or when no build command is declared, the plan has no build step.
  2. tarball: package deploy.artifacts into a release archive, a file in a private directory of this run on your machine, removed when the run ends.
  3. upload: install the archive into <path> as the connecting user, mode 600, under .zap-deploy-<user>-<version>.tar.gz. It is staged beside that name and checked against its SHA-512 before it is renamed in, so a transfer cut short installs nothing. The archive is held in memory while it uploads.
  4. extract + swap: one remote script, generated deterministically and printed whole by the dry run, does the release-directory work. It reads the archive's SHA-512 on its stdin, opens the archive once, requires it to be the connecting user's own file, writable by no one else, with that digest, and extracts through the open file, so another account on the host can't swap the archive between the upload and the extraction. Extracted files belong to the connecting user, under that user's umask, without set-id bits on files. The script removes the archive on every exit of its own. Then:
    • lock: take the app's deploy lock, a kernel flock on <path>/.zap-deploy.lock, held until the script exits. The file is never removed and holds no data; when Zap creates it, other users get no permission on it. It is opened read-only, so the connecting user and its group can hold it, the group even under a umask that gives it read alone (022). A deploy that finds the lock held stops at once, before it touches releases/, with another deploy is running for this app (exit 1); it doesn't wait. The kernel releases the lock when the script's process ends, however it ends; a lock held by a hung deploy is cleared by ending that process (fuser <path>/.zap-deploy.lock names it). The prepare commands run with the lock's descriptor closed, so a process one of them leaves running doesn't keep the lock once the script ends;
    • recover: under the lock, repair what an unfinished deploy (killed, or its connection cut) left in releases/: a staging directory is removed, and a release it had moved aside is put back under its version when that name is empty, or removed when it is filled. So a current that a killed --force left naming nothing resolves on the next deploy (a redeploy of that version needs no --force: Preflight). Each repair is a warning in the run's text report (not in a --json stream), and happens even when that deploy then fails;
    • check: still under the lock, confirm current names the release the plan read as live (by the link's last segment, trailing slashes dropped; nothing at all at current, no link and no file or directory, on a first deploy). A deploy planned before another one swapped stops here, saying current changed since this deploy was planned (exit 1), having made nothing of its own; run zap deploy again. So the release a rollback returns to, and the one the prune keeps, is the one live just before this deploy's swap. A plan that couldn't read current (it warns) stops here too when the host has one;
    • extract into a fresh hidden directory, <path>/releases/.zap-stage;
    • link shared: symlink each deploy.shared entry from <path>/shared/ into the new release (an apply-managed env file, for example); a release that ships a directory at a shared name fails the deploy here;
    • prepare: run each deploy.prepare command in the new release, before the swap, in a shell of its own (sh -ec): an exit or exec in one ends that command, not the script, and a command that fails stops the deploy before the swap. Its working directory is the hidden one, not the release's final path, and its output goes to the script's error stream: a step that fails shows the script's own reason when it stopped itself, or else the stream's last lines, and what a command prints becomes no run warning. A command that reaches the script's own output anyway (through /proc, or a descriptor it was handed) can print a line the script marks, a warning or its reason for stopping, which is then read as the script's: text only, since what the deploy does turns on the exit status and the swap;
    • place: rename the complete release to <path>/releases/<version>, where the version is the commit's hash abbreviated to 12 hex digits (git rev-parse --short=12, longer only where 12 would be ambiguous), with -dirty-<id> for a forced dirty tree, above. When that name already exists (an earlier deploy of the same version, or the live release under --force), it is renamed aside first, to .zap-old-<version>, and removed after the swap, so a release never mixes files from two archives. A run that fails before this point leaves no hidden directory and changes no release beyond what it recovered; under --force of the live version, current names nothing for the instant between the two renames, and a run that fails after this point leaves the new contents live under that name, the old ones not kept. A run killed between the two renames leaves the old release aside for the next deploy's recovery to put back;
    • swap: re-point <path>/current at the new release atomically. From here the release is live, and the run goes on to restart and check it whatever the rest of the script does: what follows is housekeeping, and a failure there is a warning in the run's output, not a failed step. A script that fails after saying it swapped is taken as swapped only when current, read again, names the new release;
    • record: write <path>/deploy_info.json (version, who, when, previous release);
    • prune: keep deploy.releases releases: the one just deployed, the one it replaced, then the newest others. Only a directory named the way Zap names a release (a commit hash of 4 to 64 hex digits, alone or with -dirty-<id>) is ever removed; anything else under releases/ is left alone. As a guard, the prune removes nothing unless current names a release, and on a target with no sort it removes nothing and warns.
  5. restart: restart deploy.service through the target's service manager (with a daemon-reload first on systemd).
  6. health: poll https://<domain><path> from the operator machine (the one you run zap on) until it answers 2xx, up to attempts times, interval_ms apart. Neither poll follows a redirect: a 3xx is not healthy or ready, so a release that redirects to a login page doesn't pass on that page's 200.
  7. readiness: when health.path isn't already /ready, poll /ready too. 2xx proceeds. When the attempts run out, the last HTTP status decides; a connection failure after it doesn't replace it. A 404 means the app doesn't serve /ready: the step is recorded as skipped, warns loudly, and the deploy proceeds on liveness alone. Any other answer fails the deploy and rolls back: a 503 (the convention for a database schema that doesn't match the running code, or an unreachable database), any other status, or no answer at all. Point health.path at /ready to make that one check the gate; then any non-2xx, 404 included, rolls back.

Auto-rollback

If a step after the swap fails (the restart errors, health never goes green, /ready answers 503), Zap re-points current at the previous release, restarts, and re-checks health (the health URL, not /ready), recording the rollback as warnings and step events. The deploy still exits 1: rollback restores service, it doesn't paper over the failure.

The re-point is a compare-and-swap under the app's deploy lock, taken without waiting. It moves current back only while current still names this deploy's release and the previous release is still on the host. Otherwise (another deploy holds the lock or has replaced the release, someone re-pointed current by hand, current names nothing, or the previous release is gone) the rollback is skipped with a warning that says why: nothing is re-pointed or restarted, and the deploy still exits 1.

There is nothing to roll back to on a first deploy, or on a --force redeploy of the live version: current stays at the failed release, and the run warns that a manual rollback is needed.

When the restored release's health check fails too, the rollback step is recorded as failed, and the warning says manual intervention is needed.

The previous release is read from the live current symlink at deploy time, not from a recorded file, so a manual rollback done by hand is seen correctly on the next deploy. A current whose last segment, trailing slashes dropped, is no release's name names no previous release, so the deploy refuses before it builds anything: point current at the live release by hand, or remove it to deploy as onto a fresh host.

Concurrent deploys

A deploy planned before another deploy's swap stops at its check (step 4), so a rollback never undoes a newer deploy, with one exception: a --force redeploy of the same version, planned after a deploy's swap while that deploy is in its health check, writes the same current, which the first deploy's rollback can't tell from its own and can undo. A rollback skipped because another deploy held the lock leaves current at the failed release if that other deploy then fails before its own swap; the warning says to check it.

The lock covers the release-directory work only: the restart and the health checks run after the script releases it, so a deploy can report on a host that another deploy has since moved on. Two simultaneous deploys of one version by one connecting user upload to the same name, so either can replace or remove the other's archive; the other then stops at its digest check or finds no archive (exit 1) rather than ship bytes it didn't build.

Security model #

What Zap trusts, what it refuses to trust, and where the walls are.

The threat model in one paragraph

The operator is trusted: whoever runs zap apply --wetrun already holds SSH and sudo on the target, and could do anything by hand. Zap's job is to make what that person (or the assistant working for them) does reviewable, bounded, and observable: a config can't act on its own, nothing Zap does to the target goes unshown except the checks a config declares (they run as written on every pass, before any review, and Zap can't make them read-only), a read that fails never passes as "fine", and secrets don't leak into the places people and agents read. Zap is not a sandbox for hostile configs you didn't write; see the config is first-party.

The config can only describe

What this means for writing a config is in configs.md. The walls themselves: zap.ts runs in fgro eval, Gro's runtime, a separate binary Zap runs once per evaluation. Its walls are its own, set up before any of the config's code, or V8 itself, runs:

  • a kernel box: Landlock admits reads only under the config's project and the packages linked into its node_modules, and seccomp narrows the system calls. No write anywhere, no network socket, no exec, no read outside those directories (/proc and /dev included; metadata such as whether a path exists stays visible), and no signal to another process. There is no setting that weakens it, and on a host that can't hold it (Linux below Landlock ABI 6, an architecture its filters aren't built for) the evaluation fails rather than run without it;
  • no environment: the evaluator starts with TZ=UTC and nothing else of yours, reads no variable for its own settings, and gives the config no process and no environment API. A variable in your shell can't loosen the sandbox or reach the declaration except through zap.env(), which is a reference resolved later by Zap, never a value;
  • only static imports: every import() is refused, and a node: built-in doesn't resolve. Resolution is strict ESM: a relative import names its extension, and a bare import resolves through the node_modules of the project and the exports of the package found there (never its main), under the conditions gro, import and default;
  • no clock, no randomness, no timers: reading the clock, a random source, or a fact of the machine throws, and there are no timers; the time zone is UTC and the locale en-US (configs.md lists what is refused);
  • jitless: V8 runs in its interpreter, so WebAssembly is absent;
  • no code from strings: eval and new Function throw;
  • bounds: a capped heap and process memory, a 30 second timeout, and a cap on the JSON it may emit.

The config's only output is a JSON declaration that Zap validates against a strict schema. It cannot run commands, write files, or call back into the engine.

The config needs a project. The project is the nearest directory at or above the config whose package.json holds more than "type" and "sideEffects" (a {"type": "module"} marker in a subdirectory is passed over), outside any node_modules, looking only through directories you own that no other user can write. Zap finds it before the evaluator runs and refuses, exit 2, when:

  • the config file or its directory is writable by every user (for a config reached through a link, the mode of the file it leads to);
  • a package.json on the way up can't be read or isn't valid: not a JSON object, a "name" or "type" that isn't a string, a FIFO, socket or device (refused unopened), a link to nothing, or a path through something that isn't a directory;
  • the walk stops below a directory another user owns (the evaluator's own walk would go past it) or every user can write, or reaches /, with no project package.json found. The refusal names where it stopped and why.

The evaluator then finds the project itself and must report the same one, or nothing it wrote is used. Its box admits the project and the packages its node_modules links in from outside it; a package reached through a directory every user can write isn't read. A package that isn't installed in the project doesn't resolve: the search for a bare import stops at the project's package.json, so a node_modules above the project is never searched. npm and yarn workspaces aren't supported: a workspace member with its own package.json is its own project, and the workspace's node_modules above it isn't searched.

"You own" means the user Zap runs as. Run as root, that is root, and under sudo also the user who ran sudo (SUDO_UID), so sudo zap apply on a config in your own project walks your directories as it would without sudo.

The evaluator never runs as root. Run as root, Zap runs it as the user who ran sudo when that user owns the project, and otherwise as the project's owner; a project root owns is refused, exit 2, since no user who could already edit the config is there to run it as. A project that is the evaluating user's home directory, or holds it, is refused too: the box would admit all of it. The user it runs as must be able to search every directory down to the project, and is never dropped into group 0: a drop whose group would be root's is refused. Dropped to the user who ran sudo (who owns the project), its group is SUDO_GID (the group you ran sudo with; unset or not a number is refused); otherwise, dropped to the project's owner, that owner's primary group. A drop keeps no supplementary groups. Its home, and the primary group, come from the passwd database; a static (musl) zap reads only /etc/passwd and nscd, so a user known only through LDAP, sssd, or systemd-homed can have no entry there, and the evaluation is then refused.

The evaluator is pinned. Zap runs the fgro binary --fgro or ZAP_FGRO names, by absolute path, never one found on PATH, and only when its blake3 is the toolchain pin in the config's zap.lock; zap lock is the one command that records the pin rather than checking it (lock.md). The binary is opened once and run through the descriptor that was checked, so a binary replaced at its path afterward isn't what runs. Its blake3 is read through that descriptor, or, once the file has settled, from the pin cache (~/.zap/cache/fgro_pins/, private to the user Zap runs as): an entry there names the blake3 of a file by its device, inode, size, and modification and change times, and a file whose metadata matches an entry isn't hashed again. A write moves the change time, but only to the filesystem's timestamp granularity, so an entry is written only for a file whose times are a couple of seconds old, and a clock set back across that margin, or a network filesystem whose server's clock lags, defeats it. A write to the file itself between the check and the exec, by anyone who can write it, is caught only on a best-effort basis. A link at the binary's own name, a wrapper script, or a set-ID binary is refused.

Reads are the project's. A config reads nothing but the modules it imports, from its project and the packages the project links. What stays the operator's: the files in the project and the linked packages are trusted as they are, a package installed as a directory included. The ownership walk doesn't read ACLs, and the evaluator reads the disk again after Zap's walk: a project that changed in between is caught by its report, not prevented. With ZAP_EVAL_CACHE=skip, a cache hit is served without running the evaluator, so the project walk and the evaluator's report aren't checked on a hit; the fgro binary is still held to its pin (cli.md).

The config is first-party

Zap assumes the config and everything it imports are yours. If you ever evaluate a config you don't trust, the plan it emits is the attack surface: review it as you would a shell script, because its script resources are shell. A config can't pull code from the network or import dynamically, and its imports stay inside its project and the packages it links, so it can't take a project or a package from a directory every user can write. A linked package is otherwise trusted wherever it lives and whoever owns it.

What the gates reject before anything runs

Every command that reads a config, including config validate, runs two machine-free gates on the declaration:

  • the secret gate: a secret written literally into the config is rejected when Zap can tell (the same value is both referenced and resolvable in the current environment);
  • the structural gate: a malformed or contradictory declaration, such as a NUL byte in a string a shell would see, a duplicate id, or a dangling or cyclic dependency. The full list is in configs.md.

A wetrun adds an env gate: any referenced variable that is unset or empty fails the run before any side effect, so an empty secret never gets written into a .env file.

Zap changes nothing without a wetrun

No command changes the target unless you pass --wetrun, and no flag approves a plan for you. A dry run connects, reads, and renders. Reading runs Zap's own read-only probes and the commands the config declares as checks (a script's check, a verify's assertions, a package's command check, an apt source line's expansion), before any review. Zap runs them as written: a check that writes, writes. Its only other network traffic is fetching a missing content pin into the cache on your machine. This holds with no terminal, in CI, and in an agent loop. zap lock and zap update write zap.lock on your machine, which is their job.

A wetrun executes only the plan you reviewed

A wetrun's fresh plan must match a reviewed one hash for hash, or it refuses before any side effect; on an interactive terminal it asks you to type the start of the hash instead, a question nothing typed earlier can answer. The rule, and what plan_hash binds (the host, the connecting user, a local HOME, every plain reference's value), is in concepts.md; zap deploy binds its own plan the same way (deploy.md). So an agent given --expect-plan with the hash you approved, or a pipeline given the hash approved in a pull request, can execute only that plan. A plan whose host, connecting user, or deploy health domain resolves through a masked reference gets no hash, and its wetrun is an error (env.md).

The host a hash binds is the name the config resolves it to, not where ssh takes that name: an ssh config whose HostName, User, or Port for it changes between the review and the wetrun sends the reviewed plan to the machine it now names, if that machine's key is a known one. What ssh -G prints is configuration, not the machine, so the hash doesn't bind it; the machine itself is held by host-key checking. Binding the machine to a plan would take its host key's fingerprint, which the hash doesn't hold.

What the hash covers that a preview doesn't print

A preview prints the target and, under each change, what it declares and every step, with a file's content as a descriptor: its kind, length, and the start of its SHA-512 (for a template, of the template text as written). plan_hash also covers:

  • a file's content bytes, and any literal credential;
  • a resource's depends_on, and trigger sources;
  • package names for managers the target doesn't use;
  • the text of the scripts Zap's own operations run (the put, link, directory, extract, snapshot, pg_hba.conf, and swap-path scripts, and an installer's fetch) and the command each runs in, with its arguments, privilege, and limits, so a zap build whose scripts, or the way it calls them, differ from the one that previewed computes a different hash.

The hash is not a fingerprint of the zap binary: what it does natively on a local target, and the few commands it composes directly around a path (the rm -f that clears a pending marker or removes what a failed validated write created), are its own.

What an approved hash doesn't fix

What Zap can't know when it plans:

  • a secret's value (the plan says only that the reference is one);
  • the state of the target between the read and the change;
  • what the target's shell expands inside a command ($HOME, a variable in an apt source line), which on a local target is Zap's own environment, whose HOME an --env-file doesn't set;
  • the bytes of a source file already on the target;
  • what an installer URL serves that day;
  • the path PostgreSQL reports for its pg_hba.conf;
  • in a deploy, the artifact bytes and the environment your build and prepare commands run in.

Some commands also run before any review, as the connecting user, on every pass that reads the machine (plan, detect, apply dry or wet, the deploy preflight): a script's check, a verify's command assertions, a package's check when it is a command, and an apt source line, which the target's shell expands to compare with the list, so a $(…) in it runs at zap plan. This is one reason the config is first-party.

Escaped output

A review is only as good as the text it reads. Everything Zap prints as text writes what it takes from the config, the environment, the command line, zap.lock, a file under ~/.zap, or the target with control, bidirectional, and default-ignorable characters (every control character but tab, the line and paragraph separators, zero-width characters and the like) spelled out as <U+XXXX>, and a tab as spaces, so such text can't move the cursor, overwrite output, reorder what is shown, or sit in a line unseen. Text that is a block (a command, a script, what a failed command printed) keeps its lines, each indented under its first. The --json event stream writes the same characters as JSON \u escapes. The escape doesn't make look-alikes distinguishable: homoglyphs, wide or blank spaces, and combining marks print as they are, a long line still wraps, and text that spells <U+000D> or {env:NAME} prints the same as what Zap writes for one.

Secrets

  • A config never reads the environment. Values are references (zap.env('NAME')), resolved by Zap when it acts. A reference holds a name, never a value. No value a reference resolves to, nothing a command prints, and no file's bytes can turn into one.
  • Which references are secrets, and the forms masking looks for: env.md. A secret's value is masked out of every output: the text report, the JSON events, warnings, held reasons, drift observations, deploy step output, and errors. Masking is by value: a secret a tool prints in another form (the address ssh resolved a secret host name to) isn't found, and a secret under an ordinary name with an ordinary-looking value isn't known to be one until mask() lists it.
  • Zap hands every command to its shell on stdin, detection and apply alike. No command text, env value, or working directory is on the argv of ssh, the login shell, or sh, on either machine, so none shows in ps. File content travels on stdin too. A program a command starts still shows its own arguments while it runs, and that includes the ones Zap starts (curl <url>, psql -c <sql>). Give a program a secret through an env value or a file it reads, not as an argument.
  • A value is substituted into a command as text, into whatever quoting surrounds the reference. Zap refuses a value holding a single quote, a NUL, or a reference delimiter (U+FDD0 or U+FDD1) in a command, and a double quote in a database identifier, before anything runs: a wetrun checks every planned command first and exits 2 with nothing changed; zap plan and a dry run warn. That makes a reference safe inside single quotes, where a shell reads every other byte literally, and Zap and the shipped recipes put every value there, except the options documented as shell words (psql_args, command_args, a stop or reload command). A reference a config author writes outside single quotes, bare or inside double quotes, is read by the shell like the text around it, and keeping it safe there is the author's job. An env value is quoted after it resolves and carries anything but a NUL and the reference delimiters U+FDD0 and U+FDD1.
  • The last-run report and zap.lock hash a secret reference as a fixed token, and the report masks every registered secret out of any other value, so neither can be used to test a guess at a secret. A secret the rule doesn't recognize is hashed by value until mask() lists it (lock.md).
  • A fetch error names the URL as the config declares it, its references unresolved, and the content cache names a file from the declared URL, so a token in a URL reaches neither a message nor a path.
  • remote and build sources can carry a bearer auth token; it attaches as a request header to the source's own origin (scheme, host, and port) only: a signature_url on another origin is fetched without it, and a redirect to another origin drops it. Its resolved value is never hashed and never reaches the lock.
  • There is no --show-secrets.

Content Zap fetches

Every URL in a config is https://: a plain http://, file://, or any other scheme doesn't validate, and neither does a URL carrying user:password@. The one allowance is http:// to a loopback host for the fetches Zap makes itself, for a local mirror. Zap follows a redirect only to another https:// URL (a fetch that has stayed on loopback may be redirected on loopback), so a remote server can't send a fetch to a plain-http port of the machine Zap runs on, and a bearer auth goes only to the origin the config names.

Fetched bytes are pinned by blake3, verified against the pin before placement, and cached by content address. A vendored pin is your own statement of which bytes to trust. A tracked pin is trust on first fetch: zap lock, zap update, or the first zap apply --wetrun records the hash of what the URL served then, and every later fetch must match it. What authenticates that first fetch is TLS, plus a minisign signature when a build source declares verify, checked before the bytes are cached or pinned. A signature doesn't fix the version: an older release the same key signed is caught only by the pin (resources.md). The recorded pin is in zap.lock and in the plan hash, so it is reviewed like any other change. minisign's exit status is that verdict, so the binary is found once (the absolute path ZAP_MINISIGN names, else PATH) and run by that path, and one inside a node_modules directory, or where it or any directory on the way to it (every one up to /, links followed) is in reach of a group, another account, or anyone but root and you, is refused; a refusal or a failed check names its path. With those directories trusted, the gap between the check and the run is only root's and yours.

zap.lock is reviewed input, like the config: its config_hash detects staleness, not forgery, so review the lock's diff before committing it (lock.md).

One thing reaches the target with no pin. An installer is a script fetched whole over https and then run: whatever its URL serves today, gated only by a check path. A fetch that fails fails the step, and so does one that serves an empty script; nothing runs. Prefer a build or a pinned remote file when the upstream offers one. An apt_repo's signing key is pinned like remote content: Zap installs only bytes whose blake3 is the pin, and detection compares the installed key with it (resources.md).

Writes on the host

Zap installs a file by staging it under a new name in the file's own directory, setting owner and mode on the open file, and renaming it into place; it never follows a link at the file's name or at the staged name, and a file's or directory's mode and owner are never set by name. The bytes are checked against their SHA-512 before the rename, so a transfer cut short installs nothing. A directory's owner and mode are set on the directory itself; a link at its name is refused. A file keeps the owner, group, and mode it leaves unset from the regular file it replaces, read from the name without following a link, never with a set-id or sticky bit; a new one takes its directory's owner and group. A file with a second name (a hard link) keeps nothing, and neither does anything below a directory with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep — Zap walks up from the file's directory by .. to the root, and checks every directory on the path as written, a link read as what it leads to: there a file takes what Zap's own write gives, as root over SSH or as you locally. A mode is never kept from a file with an extended POSIX access list (ACL), whose mode bits show the list's mask: the write is refused until the config declares mode. Zap sees a list through getfacl, else through GNU ls's +; a target with neither (stock Alpine) can't show one, and keeps the mode. Elsewhere, whoever can write the directory can choose what is kept, as they could replace the file; the files Zap's steps install never keep a planted owner. So a file with a mode but no owner, written into a directory another account can write, can end up readable by that account: it inherits that account's directory, or the account plants a file there of its own for Zap to keep. Declare mode, owner, and group on any file whose content the directory's owner or group must not read: a planted file's mode is kept even when the owner and group are declared, so without a mode the content can land readable by everyone. A user's authorized_keys is written as that user, and a sudoers file is installed only after visudo -c accepts it.

A file with a validate command is read before its write, so a failed check can put it back. One script holds the file open and reads its bytes, mode, and owner through that descriptor: as root over SSH, and locally as you, escalating when refused. The change fails with nothing written when the name holds a link, anything but a regular file (a fifo is refused without waiting on it), or a file over 16 MiB. A rollback restores the bytes with the owner and group they had, and their mode without any set-id or sticky bit the declared mode doesn't carry, so root never makes a file set-id that the config didn't.

One window stays open: between the read's checks and its open, a link swapped in is opened (not read) and the read refused, unless it leads to a fifo, which holds the open until the read's five-minute timeout fails the change. A directory's owner and mode change has the same window and timeout.

Missing directories are made one at a time, each inside the one before it held open, and given, through its descriptor, the owner and group of the directory it was made in, so it belongs to someone who could have made it; a link swapped in for one is refused, with nothing made through it. The owner is given only to the empty directory Zap just made: a directory renamed over it before it was opened, by the owner of the directory it was made in, is refused and given nothing. Anywhere below a directory with its sticky bit set or that every account may write (/tmp), however deep, on the path as written or where its links lead, what Zap makes stays its own, as root over SSH or as you locally: an account that made /tmp/cache, as a directory or as a link to one of its own, can't have the directories Zap makes there given to it.

Reads of a file's bytes, as root over SSH, read only a regular file of its own: a name that is a symbolic link, a directory, a device, or a fifo is refused before it is opened, and what was opened is read only when it is a regular file and still the file at the name, so no byte comes through a symbolic link. The checks can't close the instant before the open: an account that can write where a managed file lives and swaps the name for a symbolic link in that instant has Zap open what the link names, though not read it, and opening some devices acts on the machine (a watchdog's), while a fifo holds the read until its timeout. A regular file with more than one hard link is not read either, whoever owns it: a hard link is the same file under another name, which an account can make in a directory it writes, so a root read of the name could read a file that account linked there. The link count is read from what was opened, and such a file holds its resource, with the fix: replace it with a copy of its own (a single link). Keep managed files in directories only root can write all the same. A file's source is read the same way, and a user's own authorized_keys is read as that user.

Zap does follow a link in a directory component of the path, so a managed path under a directory someone else can write can be redirected to another directory: keep root-managed files in directories only root can write. One such redirection also slips past the shared-directory checks above: a link on the path that leads through a second link sitting in a directory every account may write, off both the path as written and the directories it resolves to (/srv/app a link to /var/shared/app, itself a link the account that can write /var/shared points at a directory of its own). Neither check passes through /var/shared, so what is kept there, or given to directories made there, is that account's choice. Keep the links on a managed path out of directories others can write. The staged file is made by mktemp, mode 600 whatever the umask or a default access list on the directory would give, and held open; before a byte is written it must be a regular file of the writer's, empty, with one name, and still the file at the staged name. A local user who can write the directory could make the write fail, or stall it by swapping the staged name for a fifo; they could not send the content, mode, or owner anywhere, nor have root create a file where a link of theirs points. A run killed outright, or one refused at its stage check, can leave a .zap.* file beside its target. A target needs /proc.

The same holds for the files Zap's steps install: an apt sources list, the nftables ruleset, and /etc/fstab are each installed whole by rename, never edited in place, and so is an apt_repo's signing key. pg_hba.conf is too, but as the postgres account, not root: PostgreSQL names the file and postgres owns its directory, so a rewrite as root would write wherever that account pointed it. The rewrite keeps the file's group and mode, and refuses a file that doesn't belong to postgres or a directory it can't write; detection reads the file as postgres as well.

A remote file's or build source's archive is extracted only into a directory at its own name: a link there is refused. tar runs in a new directory inside a private staging directory (mode 0700, the extracting user's) beside the destination. The staging directory is held open from the moment it is checked: a fresh, empty directory of the extracting user's, mode 0700, at its name, and not a link. Everything inside it is then reached through the descriptor, so an account that can write the destination's parent may rename the staging directory, or put a link at its name, but can't steer where tar writes or what is removed. What tar extracts belongs to the extracting user, with no set-id bit on a file and nothing group- or world-writable, whatever the archive records. Before the rename, still inside the staging directory, the new tree is given the owner, group, and mode of the destination it replaces, read without following a link and set on the directory held open, never with a set-id or sticky bit; then it is renamed into place. Below a directory with its sticky bit set or that every account may write, checked as for a file (the parent's path as written and where it resolves), nothing is kept: the new tree stays the extracting user's, at the mode mkdir gives it. A destination that has an extended or default access list is refused, a declared mode or not; the list must be removed (setfacl -b). Such an account can still move the destination itself, or the tree once it is in place: a parent another account can write is trusted to that extent. The archive's contents are trusted as pinned: its author chooses the names inside it. Neither tar writes through a symbolic link the archive carries. BusyBox tar will make a hard link to a file outside the archive that a member names, so a new tree holding a file linked from outside it is refused, and removed before it is renamed into place; hard links among the archive's own files extract.

A remote or build archive is uploaded as the connecting user into a private staging directory of the run, then extracted as root. The extraction opens it once and goes on only when the file it holds is a regular file, not a link, owned by the account that ran sudo, with one link and writable by no one else. It then copies what it holds into its own staging directory, which only root reaches, and extracts that copy only when its SHA-512 is the digest of the pinned bytes Zap passed, so a process running as the connecting user can't swap or rewrite the archive between the upload and the extraction.

hardened_openrc_service never sources an env_file: its init script runs as root, so root never parses it: start_pre has the service user check every line (su, under a 10-second timeout), assigning nothing, and the daemon's own process, already dropped to that user, exports the values before it runs the command (recipes.md). A service user that can write its env file changes its own environment, as under systemd, and can make a start wait out the timeout or fail; the refusal start_pre prints is that user's process's words.

A release archive sits in no shared directory on either machine. On yours it is a file in a private directory of the run; on the target it is installed into the app path as the connecting user, and the release script extracts only the file it has open, after checking that it belongs to that user, is writable by no one else, and has the digest Zap sent. So another account on the host can't swap or alter it between the upload and the extraction. Nothing checks what your build put in it. A deploy trusts whoever can write the app path as much as it trusts the connecting user: they can re-point current. Deploying as root into a path another account can write lets that account redirect what root creates there, so deploy as the path's owner. A run killed outright, or a connection dropped between the upload and the script, can leave the archive at mode 600 in the app path or in your temporary directory.

Authority on the host

Zap's authority is SSH plus the host's sudo. There is no agent, server, API, or role system to compromise. Privileged commands carry an explicit sudo; on a local target file operations escalate only when denied, and over SSH they run under sudo (targets.md). Host keys are trusted on first use and refused when changed, which holds only while ssh records them: Zap reads your effective ssh config once per target (ssh -G) and refuses to connect when every UserKnownHostsFile is /dev/null. Every session has agent, X11, and port forwarding, a tunnel device, a terminal, and LocalCommand turned off, whatever your config says. The host's own sudo I/O log (Defaults log_input, replayed with sudoreplay) is the audit trail of what ran as root: the plain sudo log records one constant wrapper per detection batch, and the I/O log captures the script, resolved reference values included. It never sees the unprivileged login lane, where operator commands run. A file write appears in the sudo log as one sh -c command carrying Zap's constant install script, with the path as its argument; the mode and owner are arguments too, and the content and its digest arrive on stdin, so a host that logs sudo's input records them.

Fail closed

  • A resource Zap can't read is held, not guessed. The run exits 1, and deploy's preflight refuses to ship (exit 2). A "converged" that wasn't established is never reported.
  • A pin that isn't current is withheld, so a stale lock can't make a changed resource read converged.
  • A reference the comparison needs but can't resolve makes the whole pass unverifiable, exit 1: an empty string can both hide drift and invent it.
  • A file write with validate is rolled back when the check fails, so a service is never reloaded onto a rejected config.
  • A failed reload is remembered on the target and re-fired on the next plan.

Security controls are read live, every time

firewall, user, and database guard the host's access surface and are exactly what gets changed by hand: a ufw default allow incoming at the console, or a key appended to authorized_keys. Zap keeps no detection cache, so such a change shows on the next plan rather than hiding behind a stored verdict. The same holds for security-relevant files: an sshd drop-in, a fail2ban jail, an nginx site carrying a CSP. The exception is the lists Zap treats as additive (below): a declared entry that went missing drifts, but an extra one, such as an injected pg_hba trust line, isn't read.

Limits and defaults to know

  • Firewall allow rules, a database's pg_hba rules, and a user's groups are additive. Zap asserts that each declared entry is present (and, for a firewall, that the default policies match); an entry added out of band is neither detected nor removed.
  • A database resource writes no pg_hba rule unless it declares auth.hosts, and never a trust one: the role is reached over the Unix socket by the OS account of the same name. A trust rule already in the file (one an older config added) stays until it is removed by hand; on Alpine, alpine_postgres_setup rewrites initdb's, and any other trust line, to peer / scram-sha-256.
  • A link whose path is a regular file reads as absent and is replaced.
  • A file with no managed mode, owner, or group whose path is a symlink to matching content reads converged; the link is left as it is.
  • Zap follows a symlink in a directory component of a managed path (see Writes on the host).
  • A swap's size is not compared after creation.

Reporting a vulnerability

See the repository's security policy for how to report an issue privately.

How Zap compares #

Zap converges the inside of Linux machines you own and ships your services to them. It is a TypeScript declaration and a Rust engine over SSH, with dry run by default, no agent, and no server. This page puts it next to the tools you might otherwise reach for. It is written for someone with a few servers and a laptop, not a fleet, and it covers when not to use Zap as much as when to.

Facts about other tools name versions where they matter. If something here is out of date, please file an issue.

What Zap is: introduction.md, including the things it does differently. The verdict comes first; the side-by-side tables are at the end.

Use Zap when

  • You run a handful of Debian/Ubuntu or Alpine hosts you reach by SSH, and want their packages, users, SSH hardening, firewall, nginx, services, and an on-box PostgreSQL declared in one file.
  • You want nothing on the target beyond sshd, sudo, and a stock shell and core tools: no agent, no Python, no server, no state bucket.
  • An AI agent or a non-ops teammate will drive the tool, and you want the default command to be harmless, a plan they can show you with a hash, exit codes that separate "converged" from "drifted" from "couldn't tell", and typed events rather than log scraping.
  • You'd rather be told "I couldn't read this" than have a tool guess.
  • You deploy your own service as release directories with a health gate and rollback, and want the deploy to refuse when the host has drifted.
  • You prefer TypeScript with completion over YAML with a template language.

Don't use Zap when

  • You need many hosts in parallel or fleet orchestration. One config, one host, resources in order. Ansible, pyinfra, Salt.
  • You need cloud resources. VPCs, DNS, load balancers, managed databases. OpenTofu, Terraform, Pulumi; they pair with Zap.
  • You need removing a declaration to remove the thing. Zap only reports orphans. NixOS, or Terraform for cloud resources.
  • You need macOS, Windows, RHEL, Arch, or network devices. Ansible, chezmoi, mise.
  • You want whole-system rollback or reproducibility. NixOS, image-based systems.
  • You're container-native. Kamal, Compose, Dokku, Coolify; Zap can still converge the host underneath.
  • You need branching on mid-run results. Ansible, or a script.
  • Your machine can't run fgro. It needs x86_64 Linux with Landlock ABI 6 (kernel 6.12 or later).
  • You need a module ecosystem and community support. Zap is pre-alpha with one maintainer. Ansible.

Head to head

Ordered by how often a reader is choosing between the two.

Versus Ansible

The closest in operational shape: agentless, SSH, no server. The furthest in control model. An Ansible playbook is an ordered list of tasks; most modules converge within one task (state: present), but the playbook is a procedure, and decisions are made at run time against gathered facts, including facts an earlier task in the same run changed. Zap has no task order you author: you declare resources and dependencies, and the engine decides create, update, or skip from detection.

Ansible's --check mode is opt-in and its fidelity depends on each module: command and shell are simulated only when given creates or removes. Handlers notified by a task are lost if a later task fails, unless you set --force-handlers; Zap remembers an owed reload on the host. Ansible needs Python on the managed node for nearly every module; Zap needs a shell. Ansible's validate: on copy and template matches Zap's validate on a file.

Reach for Ansible for its enormous module ecosystem, for fleets and inventories, for Windows and network devices, or for run-time procedural flexibility. Zap is narrower and younger by orders of magnitude.

Versus pyinfra

The closest living tool to Zap's shape: agentless, SSH, generates shell so the target needs no Python, written in a real language, two-phase (facts first, then operations). The differences are where the two draw their lines. pyinfra lets deploy code read facts while deciding what to do, so the declaration can depend on the host; Zap seals the declaration before contact and doesn't read the host while deciding. pyinfra shows its changes and prompts before acting, and warns the list may be incomplete; Zap's dry run is the default and executes nothing. pyinfra has no lock file pinning fetched content, no held verdict, and no re-read after acting. pyinfra runs many hosts in parallel; Zap runs one.

Reach for pyinfra for Python, for parallel multi-host runs, or when you want the host's facts to shape the deploy.

Versus Docker Compose, Kamal, and self-hosted PaaS

For a solo developer with a VPS, this is the realistic alternative. Compose, and platforms like Dokku, Coolify, and CapRover, assume a prepared Docker host and own the application layer: images, containers, routing, often TLS. They don't converge the host: SSH hardening, the firewall, users, an on-box PostgreSQL, unattended upgrades. CapRover and a self-hosted Coolify run a control plane on your server that you have to keep; Coolify's hosted cloud keeps it for you, over servers that are still yours to converge. Dokku is git push to one box.

Kamal 2 ships Docker images and does rolling restarts behind kamal-proxy, which health-checks the new container before switching traffic, a gate that is stronger than Zap's post-swap health check plus rollback. Kamal has no dry run. kamal setup installs Docker but doesn't otherwise manage the host.

Zap deploys release directories, not containers, and its isolation comes from converging the host (a hardened systemd unit, a dedicated user) rather than from an image. The two are complementary: Zap can converge the box these tools run on.

Reach for Kamal or Compose when you're container-native or want multi-host deploys; for Coolify or Dokku when a UI or git push is the experience you want and you're happy to run their control plane.

Versus OpenTofu, Terraform, and Pulumi

The closest in discipline: declarative, converge, review a plan before acting. Terraform's plan is computed by refreshing a state file that is the authoritative record of what Terraform owns, and data sources read live infrastructure during planning, so the desired state is a function of config, state, and the world. Zap's declaration is a function of the config alone; detection decides only which declared changes run. There is no state file: the machine is read every run, and zap.lock pins content, not resources.

Credit where due: Terraform's saved plan (plan -out, then apply <planfile>) is the most operationally binding plan artifact in the field, and it refuses a plan whose state has moved since. Zap's wetrun binding does the same job for a machine, by hash. Terraform also destroys a resource you stop declaring; Zap only reports it as orphaned.

Pulumi takes "a real language" the other way across the boundary: the program runs against the live engine, resources register over RPC, and apply() callbacks run mid-deploy. Zap's TypeScript emits data and stops.

Scope differs more than model: both are cloud-resource tools with provider ecosystems. Zap converges the inside of a machine and has no providers. Pair them: OpenTofu for the droplet and DNS, Zap for what's on the droplet.

Reach for OpenTofu, Terraform, or Pulumi for cloud infrastructure, for a removed-means-destroyed model, or for mature team workflows around state.

Versus NixOS

The nearest philosophical kin: both make the declaration deterministic. Nix makes purity a precondition: builds are sandboxed, the whole system is a function of a pinned expression, and you adopt the store and the language wholesale to get reproducibility and atomic rollback. The system closure is a more complete inert artifact than Zap's declaration, and nixos-rebuild build, dry-activate, and nix store diff-closures let you inspect it before switching. deploy-rs adds a magic rollback over SSH; nixos-anywhere installs a machine from nothing.

The difference is prevention versus measurement. Nix prevents drift in what it owns and is silent about the rest (/var, database contents, mutable users); activation reasserts without reporting. Zap reads and reports drift on every run, including the security controls, and repairs it. You keep your distro and your package manager; you give up atomic generations and whole-system rollback. Guix System is the same model in Scheme; image-based systems (bootc, Fedora CoreOS, systemd-sysext) are the other prevention answer: don't converge, replace.

Reach for NixOS for whole-system reproducibility and atomic rollback, if you're willing to adopt the model end to end.

Versus Puppet, Chef, and Salt

The same convergence model, a different operational one: agents that pull and converge continuously, usually against a server, with mature resource models and fleet tooling. All three run masterless too.

Puppet deserves specific credit: it compiles a catalog, an inert JSON resource graph, before applying, and the catalog can be compiled without applying and diffed. The contrast with Zap is that the catalog's inputs include facts gathered from the node, which is exactly the host-reactivity Zap declines to model (below). Chef's why-run is best-effort by its own documentation. Salt's test=True and state.show_highstate give an inspectable compiled state; salt-ssh is agentless but needs Python on the target unless it ships Salt's bundled runtime (--relenv).

Puppet and Chef have both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc) carrying the free builds. Salt is actively released under Broadcom, with many modules moving to community extensions.

Reach for one of these for a large fleet that wants continuous, pull-based convergence and a deep existing ecosystem.

Versus Capistrano, Deployer, and Mina

On the deploy axis the comparison shifts: this is release push, not convergence. Capistrano and its kin ship a versioned release into releases/ and swap an atomic current symlink, the same model Zap's deploy uses, with keep_releases (default 5) and a manual deploy:rollback. None has a built-in health gate or automatic rollback; Capistrano usually clones git on the server, where Zap builds locally and uploads a tarball. Mina generates one bash script per deploy and prints it with --simulate, as Zap's dry run prints its remote script. Mina is dormant: no release since 1.2.5.

Zap's additions are the drift preflight (the deploy refuses to ship onto a host whose convergence config has drifted), the automatic rollback on a failed health gate, and that deploy rides the same engine as convergence.

Reach for Capistrano or Deployer for a mature multi-host release tool with a plugin ecosystem in the Ruby or PHP world.

Versus plain shell and cloud-init

The honest baseline: a setup.sh, a Makefile, a cloud-init file. These are imperative bootstrap: top to bottom, no idempotency guarantee, no notion of current state, no dry run, and cloud-init runs most modules once per instance rather than as a loop. Zap's value over this baseline is exactly what convergence buys: skip what's done, re-run safely, preview by default, report after.

Reach for plain shell when the task is one-shot, tiny, or so bespoke that a resource model is more ceremony than it saves.

Personal machines: mise, chezmoi, home-manager, Brewfile

Zap also sets up a developer's own machine: build tools, a terminal, dotfiles, packages. The dedicated tools are better at their one job: mise pins tool versions with a lockfile, chezmoi diffs and templates dotfiles across machines and supports macOS and Windows, home-manager brings the Nix model to a user's environment, brew bundle declares macOS packages. Zap's case is one model for the laptop and the servers, with the same file, pkg, build, and script resources, and the same dry run. It doesn't run on macOS.

How each tool reacts to the host

Zap's declaration is sealed before the machine is touched: the config runs with no network and no environment, and detection then decides only which declared changes run. That is why there's no state file, why the plan has a stable hash a wetrun can be bound to, and why secrets stay inert until use. It splits "reacting to what's on the host" into three cases:

  1. Detection: gating a declared action on observed state ("install only if missing", "restart only if the config changed"). This is the convergence loop itself, plus a script's check, verify assertions, and triggers. Under convergence you don't write "if X, do Y" when Y is idempotent; you declare Y and detection skips it.
  2. Derivation: desired state computed from host facts ("swap is twice the detected RAM", "apt on Debian, apk on Alpine"). Terraform does this with data sources, Puppet and pyinfra with facts, cdist with explorers. Zap doesn't read the target during eval; a script that computes on the target covers these cases.
  3. Mid-run reactivity: decisions from results a prior step produced. That is orchestration (Ansible's when: over run-updated facts, a shell script), and Zap doesn't do it. Put that logic inside one script, or compose Zap with a tool built for it.

A script's run is arbitrary shell, so the cost is legibility, never capability. Two caveats: on a local target the config runs on the machine it describes, so it could read host state at eval time (outside the model, not prevented); and determinism is by construction for the common sources (the clock and randomness throw, the time zone and locale are fixed) rather than proven, where Nix's sandboxed evaluation is the stronger claim.

Also in the space

cdist: agentless SSH push, type-based, generates shell from explorers run on the target; the closest historical kin, now low-activity. mgmt: a declarative, event-driven engine with a real-time graph that converges continuously, Zap's polar opposite on timing. Fabric, Rex, Puppet Bolt / OpenBolt: imperative remote task runners. Kubernetes: the purest convergence model there is, continuous reconcilers over etcd, and for a handful of VPSes the cost is the cluster itself. Dagger: a CI/CD pipeline engine, not a host tool; Zap isn't a CI engine.

At a glance

One table per axis. Zap's cells are exact; the others are our best understanding and are kept general on purpose.

Model: how each tool decides what to do

ToolControl modelDesired state fixed whenCan read the target while deciding?Plan artifactPlan binds the apply?
Zapconverge, one-shotat eval, before contactno (SSH); possible but outside the model (local)changeset with a blake3 hashyes: a wetrun must match the reviewed hash
Ansibleordered idempotent tasksat run time, per taskyes (facts, register)none; --check simulatesn/a
pyinfraordered operations over factsafter fact gathering, before executionyes (facts)the change summary before the promptno
Terraform / OpenTofuconverge, graphplan timeyes (data sources)saved plan file; JSON via show -jsonyes: apply <planfile> refuses a stale plan
Pulumiconverge, live programduring the updateyespreview; saved update plans (experimental)experimental
Puppetconverge, graphcatalog compileyes (node facts)catalog (JSON), compilable without applyingthe agent applies the catalog
Chefconverge, ordered resourcescompile phase on the nodeyes (Ohai, arbitrary Ruby)none; why-run is best-effortn/a
Saltconverge, requisitesrender timeyes (grains, pillar)highstate data (show_highstate)no
NixOSbuild then activateeval time, pure with flakesnothe system closure, a hashed store pathyes: you activate the closure you built
cdistconverge, types to shellafter explorers runyes (explorers)generated shell (-n)no
Kubernetescontinuous reconcileon API writen/akubectl diff, server-side dry runno
Capistrano / Deployer / Minarelease push, procedurerun timeyesnone (Deployer --plan lists tasks; Mina --simulate prints the script)n/a
Kamalcontainer release pushrun timen/anonen/a
Compose / Dokku / Coolifycontainer appson deployn/anonen/a

Operations: what you run and where

ToolOperator machine needsTarget needsServer or state backendState of recordReaches the target byMany hosts
Zapone binary plus fgro (Gro's runtime), sshsshd, POSIX shell, sudononethe live machine, plus zap.lock for pinsSSH, or localone host per config, resources in order
AnsiblePythonSSH plus Python (except raw)none (AWX optional)the machineSSH; ansible-pullyes, inventory, parallel forks
pyinfraPythonSSH plus shellnonethe machineSSH, local, Dockeryes, parallel
Terraform / OpenTofuone binary plus providersn/a (APIs)local state file by default; remote backend with locking for teamsthe state fileprovider APIsn/a: resources, not hosts
Pulumibinary plus a language runtimen/aPulumi Cloud by default, or self-managedthe state backendprovider APIsn/a
Puppet / OpenVoxserver, or puppet applyagent with bundled Rubyserver plus PuppetDB, or masterlessthe machine, plus catalogs and reportsagent pull every 30 minyes, fleet scale
Chef / Cincserver, or local modeagent with bundled RubyChef Infra Server (the open-source server is end-of-life; Chef 360 or the Cinc Server fork)the machine plus node objectsagent pullyes
Saltmasterminion, or salt-ssh with Python on the targetmaster, or masterlessthe machine plus job cacheZeroMQ or SSHyes
NixOSNixNixOSnonethe Nix store and generationslocal, --target-host, deploy-rs, colmenavia deploy-rs or colmena
cdistPythonSSH plus shellnonethe machineSSHyes, parallel
Kuberneteskubectla clusteretcdetcdAPI serveryes
Capistrano / Deployer / MinaRuby or PHPSSH, git for Capistranononereleases/ on the hostSSHyes
KamalRuby, or its Docker imageSSH plus Docker (installed by kamal setup)a container registrycontainers on the hostSSH plus registryyes, rolling
Coolify / CapRovera browserDocker plus their control plane on a serveron-server control planeits control planeits UI or agentyes
DokkugitDocker plus Dokkuon the serverDokku's configgit pushone server (schedulers for more)

Safety: what protects you from a bad run

ToolDry-run postureDry-run fidelityA read that failsAfter actingSecrets at restRollback
Zapdefault; --wetrun to act; on a terminal with nothing reviewed, asks for the start of the plan_hash (no flag answers it)full detection, nothing executesheld: out of the plan, exit 1re-detects; anything not converged is RESIDUAL, exit 1none stored; references resolved at apply and maskeddeploy: previous release, health gate; convergence: re-apply the old config
Ansibleopt-in --check (+ --diff)per module; command/shell need creates/removestask error or unreachablenone built in (assert tasks)Ansible Vault, encrypted in the reponone built in
pyinfrashows changes, then prompts (-y skips); --dry exits afterfact-based, may not include every changeerrornoneuser-managednone
Terraform / OpenTofuplan, then prompt (-auto-approve skips)high; "known after apply" unknownsrefresh fails the plannext plan shows driftplaintext in state (OpenTofu 1.7+ can encrypt; Terraform ephemeral values from 1.10)re-apply the old config
Pulumipreview, then prompt (--yes skips)higherrornoneencrypted per stacknone built in
Puppetopt-in --noophigh for native types; exec guards run, commands don'tresource fails, run continuesnext runHiera eyaml and the likere-apply the old catalog
Chefopt-in --why-runbest-effort, with assumptions; Chef's own blog calls it harmfulerrornext runencrypted data bags, Vaultnone
Saltopt-in test=Trueper moduleerrornonepillar, GPG renderernone
NixOSbuild or dry-activate before switchthe build is exact; activation partlythe build fails before touching the systemn/aworld-readable store unless agenix or sops-nixatomic generations, boot menu
Kubernetes--dry-run=server, kubectl diffhighreconciler retriescontinuousbase64 by default; encryption at rest opt-inrollout undo
Capistrano / MinaMina --simulate; Capistrano noneprinted scripterrornonelinked_filesdeploy:rollback, manual
Kamalnonen/aerrorkamal-proxy health-checks the new container before switching.kamal/secrets with password-manager adapterskamal rollback <version>

Scope and maturity

ToolPrimary scopeAuthoringConfig typed?OS coverageMaturity
Zapinside a Linux box, app deploy, dev toolsTypeScript pure function to JSONyes: TS types plus a strict schema at planDebian/Ubuntu, Alpinepre-alpha, one maintainer, small fixed resource set, no plugins
Ansiblemachine config and ad-hoc opsYAML plus Jinja2lint onlyvery broad, Windows and network devices includedhuge; Galaxy collections
pyinframachine configPythonPython typingbroad POSIXsmall to medium; active (3.x)
Terraform / OpenTofucloud resourcesHCLschema-validatedn/ahuge; provider registry. Terraform is BUSL-licensed from 1.6; OpenTofu is the MPL fork
Pulumicloud resourcesTS, Python, Go, C#, Java, YAMLyesn/alarge
Puppet / Cheffleet machine configPuppet DSL, Rubypartialbroad, Windows includedlarge and mature; both moved their official builds behind commercial terms, with community forks (OpenVox, Cinc)
Saltconfig, remote execution, eventsYAML plus Jinja and other renderersnobroadlarge; actively released under Broadcom, many modules moving to extensions
NixOSthe whole OSNixmodule system checks at evalNixOS (nix-darwin for macOS)large; steep curve
Kamalcontainer app deployYAMLnoany Docker hostmedium; 37signals-backed
Compose / Dokku / Coolifycontainer appsYAML or a UInoany Docker hostlarge
mise / chezmoi / home-managerdev tools, dotfilesTOML, templates, NixnoLinux and macOS (chezmoi: Windows too)medium to large

FAQ #

The config

Why TypeScript for the config? Because the config needs loops, constants, functions, and types, and inventing a DSL for those is how YAML grows a template language. TypeScript has them, editors understand it, and the shipped recipes are ordinary functions. The config only describes: it runs in a sandbox and emits JSON. Zap evaluates it once, before it reads the machine; what executes is the plan built from that JSON, and none of your TypeScript runs on the target.

What runs the config? Can I use Node, Deno, or Bun? fgro eval, Gro's runtime. Zap is a compiled Rust binary and can't run TypeScript itself, so fgro evaluates the config in a kernel sandbox that reads only the config's project and the packages it links (the sandbox). Each project pins one fgro build by hash in zap.lock. Node, Deno, and Bun have no such sandbox, so Zap doesn't support them. Nothing runs on the target but shell.

Where are secrets stored? Nowhere Zap owns. They live in your environment or an env file you keep out of version control, referenced with zap.env('NAME'), and resolved when Zap acts. Resolved values are masked in every output, and the lock file and last-run report hold a fixed token for each recognized secret (env.md; list others with zap.mask()).

What is zap.lock and should I commit it? It pins the fgro build that evaluates the config, and fetched content (a remote file, a build source) to the hash of the bytes you approved. Commit it with zap.ts. It changes only when you run zap update, zap lock, or an apply --wetrun that has a tracked source to pin. lock.md.

Running it

Why is the dry run the default? So the safe thing is the zero-effort thing, and so a script, a CI job, or an assistant can't act by accident. Tools that default to acting rely on an interactive prompt, which is exactly what gets auto-approved. Here --wetrun is the act, and a wetrun with nothing reviewed asks for the first characters of the plan's hash, which nothing can type before the plan is on the screen.

What does --wetrun refusing mean? A wetrun executes only the plan you reviewed. If its fresh plan doesn't match the most recent preview on that host, or the hash given to --expect-plan, it shows the plan, changes nothing, and exits 2; running it again executes if nothing moved. On an interactive terminal without --expect-plan, it asks instead: type the start of the plan's hash to run it. concepts.md.

Why did zap plan exit 0 when there are changes, but zap apply exit 2? plan reports; it doesn't judge. apply judges: 0 converged, 2 changes pending, 1 something couldn't be established. Use apply in a script that needs the verdict. cli.md.

What is undetermined? Zap couldn't read the resource and refuses to guess. It is held out of the plan, reported with its reason, and the run exits 1. The usual causes: a remote path using ~ with no home declared, a tracked remote file with no lock entry, a probe that failed or timed out, sudo that wouldn't authenticate. Fix the cause and plan again; nothing carries over. concepts.md.

Why does a script run on every apply? Its check fails on a converged host. The check is the whole idempotency contract: make it exit 0 exactly when the work is done. After a wetrun Zap runs the check again and reports RESIDUAL if it still fails.

Why does apply write to /var/lib/zap/pending/ on my host? Each marker there is a restart or reload a service still owes, written before the change that triggers it and cleared after. It is how a reload a failed run owed survives to the next plan (concepts.md). The rest of what Zap leaves on a target, beside what you declared, is listed in install.md.

Scope

Does Zap need anything installed on the target? sshd, a POSIX shell with coreutils or BusyBox, and sudo. No agent, no Python, no runtime. targets.md has the per-distro list.

I removed a resource from the config. Why is it still on the machine? Zap never removes what you stop declaring; it reports it as orphaned in zap status. To remove something, declare the removal (a script whose check confirms it is gone, or sudo: false on a user). This is the same posture as Ansible and Puppet, and the opposite of Terraform and NixOS.

Can one config manage several hosts? One target per config. For a few hosts, one config per host (sharing modules), one env file per host, a shell loop. For a fleet with parallel execution, Zap is the wrong tool; see comparison.md.

Does Zap work on macOS, Arch, Fedora, Windows? No. Debian/Ubuntu and Alpine. pacman, brew, and launchd parse in the schema but have no backend, so their resources read undetermined. Running Zap from macOS against a Linux host is unsupported: fgro's sandbox needs Linux, so your side does too.

Is Zap stable? No: pre-alpha, one maintainer, and the schema and CLI change without compatibility layers. stability.md names the surfaces whose breaks a release calls out. Read security.md before pointing Zap at a machine you care about.

Agents and alternatives

Can an AI agent run Zap? Yes, and it is designed for that: the default command is read-only, the plan is a hash-named fact the agent can show you, the wetrun executes only that plan, and --json streams typed events. Give the agent zap plan and zap apply freely and keep --wetrun for the human, or let it wetrun with --expect-plan set to the hash you approved.

How is this different from a shell script with if guards? That script is reaching toward what Zap declares: detection (skip what's done), idempotent re-runs, a preview before acting, and a report after. Zap adds the parts a script never grows: a refusal to guess when a read fails, a re-read after acting, secrets masked everywhere, and a review bound to the act. script resources let you keep the shell where it is the right tool.

How is this different from Ansible / Terraform / NixOS / Kamal? comparison.md, with tables.

What you can rely on #

Zap is pre-1.0. Anything can change between minor versions, and nothing is kept for compatibility: no deprecation period, no aliases for renamed things. What this page promises is narrower: which surfaces count as Zap's interface, so a change to one is named in the release notes, and what an upgrade throws away on purpose.

The interface

A change to any of these is a breaking change, and the release notes name it.

SurfaceWhere it's defined
The zap.ts config: the builder (zap.pkg, zap.file, …), every resource kind and field, and zap.env('NAME') references@fuzdev/zap/zap.ts, @fuzdev/zap/schema.ts, resources.md, env.md
How a config is evaluated: a pure function of the builder, in a sandbox, and what it can't reachconfigs.md
Recipe module paths and their exported functions, including the debian_* / alpine_* pairsrecipes.md
CLI commands, flags, and exit codescli.md
The --json event schema a wetrun emitscli.md
The zap.lock format ("version": 2)lock.md
The env file --env-file readsenv.md
The deploy layout on the target (releases/ and its release names, current, shared/, deploy_info.json, the .zap-deploy.lock file) and the health and readiness contractdeploy.md
Zap's state on the target: one marker per owed restart or reload under /var/lib/zap/pending/, a build's <output>.zap-build provenance sidecar, and an extracted remote file's .zap_remote_hash sentinelconcepts.md, detection.md

zap.lock is the one file Zap writes that you commit, so it carries the one migration promise: if its format version ever moves, Zap will read the old version and rewrite it in the new one, so you won't re-mint it by hand.

Discarded on upgrade, by design

These are derived by the binary that made them. A new version may compute them differently, and when it does the old ones stop matching. None of them breaks silently: Zap plans again, and the review gate asks for a new review.

  • Plan hashes. A plan_hash from one Zap version may not bind a wetrun on another. After an upgrade, a recorded preview or an --expect-plan <hash> can refuse; preview again and review the new plan.
  • The last-run report and the deploy preview under ~/.zap. A record in another format is ignored, the same as having none.
  • Each lock entry's config_hash. When the way a declaration is hashed moves, every tracked entry reads stale: zap apply stops with an error naming zap update <id>, and the read-only commands hold those resources until it runs. zap update fetches again and re-mints. Because it fetches, a source whose bytes changed upstream comes back with a new pin, so read the lock's diff before you commit it.
  • Provenance sidecars and extract sentinels on the target. When what they record moves, each build rebuilds and each extracted remote file re-extracts once, on the next wetrun.
  • The evaluation cache. Internal, and rebuilt as needed.

Not part of the interface

  • The human-readable output: plan rendering, wording, banners, layout. A script should read exit codes and --json events, not text.
  • The Rust crates. They aren't published; the zap binary is the interface.
  • Anything the docs describe as internal or as a current limitation.

CLI reference #

Every zap command, its flags, what it reads and writes, and its exit codes. For what the commands mean, see concepts.md; for a first run, see getting-started.md.

zap <command> [flags] [config]

Every command that takes a config defaults it to ./zap.ts. zap with no command prints the loop and exits 0; zap --help prints the command list, and --help on any command prints its flags.

Commands at a glance

CommandDoesTouches the targetWrites
zap plandetect, then diff desired against livereadslast-run report
zap applydry run: the plan, as apply would run itreadslast-run report
zap apply --wetrunexecute the reviewed plan, then re-detectchanges itzap.lock, pending-trigger markers on the target, last-run report
zap deploydry run: the release plan, every step in fullreads current over SSHthe deploy preview
zap deploy --wetrunexecute the reviewed release plan: build, upload, swap, restart, health-checkchanges itreleases, current, deploy_info.json and the deploy lock on the target, last-run report (preflight); withdraws the deploy preview
zap detectthe live verdict per resource, no diffreadslast-run report
zap statusthe last run's verdicts, offlinenonothing
zap config validateeval the config and run the machine-free gatesnonothing
zap lockpin the fgro build and rebuild zap.lock from upstreamno (fetches URLs)zap.lock
zap lock --checkclassify zap.lock against the confignonothing
zap update [ids…]re-fetch and advance lock pinsno (fetches URLs)zap.lock
zap versionprint the versionnonothing

All of them but version evaluate the config first, with the fgro build zap.lock pins (zap lock pins the one it is given), and run the two machine-free gates: a hardcoded secret literal or a structural error (a duplicate id, a dangling depends_on, a cycle, service intent on a target with no service_manager, …) fails every command with the same message. See configs.md.

Shared flags

FlagOnMeaning
--fgro <path>every config commandthe fgro binary that evaluates the config, by absolute path; overrides ZAP_FGRO. Under sudo, which resets the environment, the flag is the only way to name it (install.md).
--env-file <path>every config commandload NAME=VALUE lines into the operator environment before anything resolves (env.md)
--no-cachedetect, plan, apply, deployre-evaluate the config even under ZAP_EVAL_CACHE=skip, which otherwise serves a cache hit; by default every command evaluates fresh. The other commands that read a config take no --no-cache and serve a hit under skip. Only the config's evaluation is cached; the target is always read live.
--bootstrapdetect, plan, apply, deployconnect as the target's bootstrap_user instead of user; a different plan from the primary user's (targets.md)
--jsonapply, deploystream events as JSON lines to stdout during a wetrun (no effect on a dry run); a wetrun that ends before its first event prints its text report to stderr
--wetrunapply, deployexecute. Without it neither changes the target.

zap plan

zap plan [--diff] [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]

Detects every declared resource live and renders the changeset: one [+] (create) or [~] (update) line per resource, its steps under it, and the plan_hash at the end. --diff adds per-resource current → desired attribute lines above a drifted resource's steps; every change still shows its steps, so the view prints everything the plan_hash covers, as zap plan does.

Under the header come the lines naming the machine and environment the plan is for. They are part of the plan and its hash, so a preview made as one user or under one HOME doesn't bind a wetrun as another: sudo zap apply --wetrun runs as root and refuses a review made without it.

LinePrintedShows
host:remote, when the declared host is a referencethe host it resolves to
user:remotethe connecting user, or (none — ssh chooses)
user:localthe user Zap runs as
home:localthe HOME a ~ resolves to (an --env-file's value included), or (none — HOME is unset)
target:alwaysthe declared target as one line of JSON ({"local":true}), a reference in its exact form ({"$env":"NAME"})
references:when the changes use referencesone line each: NAME = value, NAME is a secret: …, or NAME is unset or empty: …. A value past 200 characters is cut, with the rest's length and the start of the whole value's blake3.

Under each change come the lines that say what it declares (its name: when it has one, a file's path:, mode:, and content: descriptor, a link's to:, a script's check:, cwd:, and env:, an apt_repo's key: URL and the pin: its bytes must have, a swap's size:, a pipeline's max_concurrency: and its step: ids and step name: labels), then its steps. A content: descriptor's SHA-512 is of the text as written: for a template, the template before its variables are filled, followed on the same line by its vars and their kinds, each as JSON. Multi-line text prints indented under its first line, and control and invisible characters print as <U+XXXX> (security.md).

A step line starts with >, #, or =. > is a command Zap runs as written. # is never run as written: it is a verify assertion Zap checks itself, or a step the target's manager has no command for. = is one of Zap's own operations, named with what it acts on and never printed as the script that performs it:

[+] deploy (user) > id -u -- 'deploy' >/dev/null 2>&1 || sudo useradd -m -d '/home/deploy' -s '/bin/bash' -- 'deploy' > sudo chown -h -- 'deploy:' '/home/deploy' > sudo usermod -s '/bin/bash' -- 'deploy' = dir /home/deploy/.ssh (mode 700, owner deploy:deploy, as root, created if missing) = put /home/deploy/.ssh/authorized_keys (mode 600, as deploy; 126 bytes, sha512 ef415c5c6a4db94b…) text: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyOne deploy@laptop ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyTwo deploy@ci > sudo usermod -p '*' -- 'deploy' = put /etc/sudoers.d/deploy (mode 440, as root, checked by visudo; 86 bytes, sha512 2434fdb3c08c85f2…) text: deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart app, /usr/bin/journalctl -u app [+] pgdg (apt_repo) key: fetched from https://www.postgresql.org/media/keys/ACCC4CF8.asc pin: blake3 bb84ad7105529a9bf29a01dca1d2e400576031b070e396a9f0c669bf75457192 = put /etc/apt/sources.list.d/pgdg.list (mode 644, as root) from: the line below, as the target's shell reads it inside double quotes after reading /etc/os-release: a variable in it expands and a $(…) in it runs, at every plan too line: deb [signed-by=/usr/share/keyrings/pgdg.asc] https://apt.postgresql.org/pub/repos/apt $VERSION_CODENAME-pgdg main > sudo apt-get update
  • = put <path> installs a file (security.md). The parentheses give its mode, the account the write runs as, and a check the staged file must pass. Text Zap holds prints whole under text:, less its final newline, with its length and the start of its SHA-512; a text with no final newline says so in the parentheses. Text the target produces has a from: line saying where it comes from and what skips the put. Text the config supplies for it prints under line:: an apt source line (which the target's shell expands, at every plan as well as on apply, so a $VERSION_CODENAME in it is read there and a $(…) in it runs), an /etc/fstab entry.
  • = run the script fetched from <url> (piped to <shell>) is an installer: the URL and the shell as the config declares them.
  • = dir <path> creates a directory if it is missing and sets its mode and owner.
  • = check <path> (not a link, below directories only root writes, as root) comes first in a swap file's creation, and refuses a link at the path or a directory on the way to it that another account can write (resources.md).
  • = pg_hba rule: <line> adds the line to the server's pg_hba.conf, as the postgres account, unless it is already there. A line it adds is put in force by reloading the server's configuration; a line already there reloads nothing. A file that doesn't belong to postgres is left unchanged and the step fails, and so does a file the server can't parse (pg_hba_file_rules lists an error, checked before the reload), a reload it doesn't confirm, or a reload it doesn't load within 30 seconds.

A = step that fails is reported by the same words, in the run's report and in the change_failed event's error: put /etc/sudoers.d/deploy (mode 440, as root, checked by visudo) exited 93: ….

A pass that can't establish a verdict says so before the plan, under an UNDETERMINED, UNVERIFIABLE, or LOCK banner.

Exit codes:

CodeMeaning
0every resource was read, and every reference and pin resolved. Pending changes still exit 0: plan reports, it doesn't judge.
1something is unestablished: a held resource, an unresolved reference, or a lock standing error

The pass is recorded as a preview, so a following zap apply --wetrun binds to its plan (concepts.md). A plan whose host or connecting user resolves through a masked reference names no one machine: it is shown under an UNBINDABLE note with no plan_hash, and is not a preview. When it is the host, the pass also keeps no last-run report and says so under an UNRECORDED note.

zap apply

zap apply [--wetrun] [--expect-plan <hash>] [--json] [--no-lock] [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]

Without --wetrun, the same pass as plan, rendered as what apply would do, and exit codes that judge the result (below). It also resolves any missing tracked pin over the network and reports it as resolved, not yet written to zap.lock; the write waits for a wetrun.

--no-lock leaves the lock's entries alone: no tracked pin is read, nothing is fetched to pin a tracked source, and nothing is written. A tracked source then has no pin, so its resource holds; a config with none runs as usual, offline. The toolchain pin is still read, and without one the command refuses as any other does.

With --wetrun, apply:

  1. rejects the run if any variable the config references is unset or empty, or if the target's host or connecting user resolves through a masked reference;
  2. detects and plans;
  3. checks the plan against the review (next section) and refuses if it doesn't match, or on an interactive terminal shows the plan and asks;
  4. refuses if any planned command can't take a value its reference resolves to (a single quote in a command, for one), naming the resource and the variable; zap plan and a dry run warn about the same thing ahead of time;
  5. claims the preview it bound to, so one preview covers one wetrun: of two wetruns started on one preview, the one that claims it first goes on and the other stops with nothing changed (exit 2);
  6. re-checks that every reference the plan recorded still resolves to the recorded value, refusing (exit 2) if one moved while the run was in progress;
  7. writes zap.lock when the config has tracked pins;
  8. executes the changes in dependency order, stopping at the first failure, writing a pending-trigger marker on the target before the first source of a triggered service and clearing it after the service's change;
  9. re-detects every resource and reports any that still isn't converged as RESIDUAL.

Binding to the review

A wetrun executes only the plan you reviewed: the plan it computes must equal, hash for hash, the one named by --expect-plan <hash> or the one in the most recent preview on this host. Otherwise it refuses, changes nothing, and exits 2. The rules are in concepts.md.

On an interactive terminal, a wetrun whose plan has no review (no preview, or a preview of a different plan) and no --expect-plan shows the plan and asks instead:

$ zap apply --wetrun apply — target local (wetrun, awaiting confirmation) user: ops home: /home/ops target: {"local":true} [+] notes (directory) path: ~/notes mode: 755 [+] hello (file) path: ~/notes/hello.txt content: inline (15 bytes, sha512 0c7463f032f1dc98…) UNREVIEWED — this plan is not the one that was reviewed. no plan has been previewed on this host. plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61 Apply this plan? Type 5bb79cc9, the start of its plan_hash, to confirm: 5bb79cc9 apply — target local (wetrun, confirmed) user: ops home: /home/ops target: {"local":true} ok notes ok hello applied: 2 ok, 0 failed, 0 skipped (of 2) plan_hash: 5bb79cc9141942676c784abba55826f12be51b3feb86f5f0cf77666545674d61

The plan and the question go to standard error; the report goes where it always does. The answer is the first eight characters of the plan's plan_hash, in either case. Input typed before the question appears is discarded, so neither a y nor an earlier run's answer typed ahead while Zap reads the machine runs anything. Those characters execute the plan shown and nothing else; any other answer (a y, the whole hash, an empty line, the end of the input) declines: the plan is rendered again under a DECLINED banner, recorded as the preview, and the exit is 2, as for a refusal. There is no flag that answers for you. Interrupting at the question (Ctrl-C) ends the run with nothing executed and nothing recorded as a preview, so the next wetrun asks again. It never asks under --json, for a mismatched --expect-plan, for a plan with no hash, or for a plan a command can't run.

Scripting. The question appears only on an interactive terminal, for a plan with no review. A script stays non-interactive by previewing first (a dry run, then the wetrun), by passing --expect-plan <hash>, or by running with standard input that isn't a terminal (< /dev/null). A non-interactive wetrun with no review of its plan refuses, and --json never asks.

--expect-plan makes the binding explicit: the hash from the plan you reviewed, perhaps in a pull request or an agent's transcript. The match is exact. A dry run rejects the flag. zap deploy follows the same rule against its own preview (below).

Exit codes:

CodeDry runWetrun
0no changes pendingevery change completed, and the re-detection read everything converged
2changes pending; --expect-plan without --wetrunrefused: the plan was not the reviewed one (or was declined at the prompt), its preview was claimed by another wetrun first, or a command can't take a resolved value; or an error before anything runs: the host or connecting user is behind a masked reference
1a held resource or an unresolved referencea failed change, a residual resource, a failed re-detection, a held resource, or a preview that couldn't be withdrawn

zap deploy

zap deploy [--wetrun] [--expect-plan <hash>] [--force] [--skip-build] [--skip-preflight] [--json] [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]

Ships a release to a remote target: build locally, tarball the artifacts, upload, extract into releases/<version>, swap the current symlink, restart the service, poll health. Full mechanism: deploy.md.

A dry run reads zap.lock, connects over SSH to read the live current symlink, prints the plan (the resolved host, the declared target, the tree and preflight state, and every step with its command, script, paths, or URL), ends with the plan_hash, records the plan as this host's deploy preview, and exits 0. If the live release is already this version it reports server already at this version, prints no hash, records nothing, and exits 0, on a dry run too; --force deploys anyway.

A dry run prints no plan_hash and records no preview when:

  • the host, connecting user, or health domain resolves through a masked reference;
  • the checkout has uncommitted changes or untracked files and no --force (never reported as already at this version, since the tree isn't what the live release holds).

A wetrun of either is refused.

A wetrun executes only the reviewed plan, as zap apply's does, but against the deploy preview: its plan's hash must equal --expect-plan or the recorded preview's, or it refuses before the preflight and any step, becomes the new preview, and exits 2 (on an interactive terminal with no --expect-plan it asks for the start of the plan_hash instead). A wetrun that passes withdraws the preview before its first step, so one recorded preview covers one execution. zap status doesn't show the deploy preview. The rules are in deploy.md.

Flags:

FlagMeaning
--expect-plan <hash>bind to this hash instead of the recorded preview: execute only if the plan has exactly it. Wetrun only, and not single-use.
--forcedeploy an unchanged version, and version a dirty tree <hash>-dirty-<id>, named by its changes. Without it a wetrun of a dirty tree is refused.
--skip-buildleave deploy.build out of the plan; ship the artifacts as they are
--skip-preflightskip the drift preflight. The machine-free gates and the env check still run.

--skip-build, --skip-preflight, --force on a dirty tree, and --bootstrap each change the plan, and so its hash: a wetrun binds to a preview made with the same flags.

Exit codes:

CodeMeaning
0dry run rendered, release deployed, or already deployed
2refused: the plan was not the reviewed one, was declined at the prompt, or its preview was spent by another run first
2the preflight found drift, or couldn't read every resource
2a wetrun whose host, connecting user, or health domain resolves through a masked reference, or from a dirty checkout without --force
2--expect-plan on a dry run; a zap.lock that doesn't parse
1a step failed, including the release script finding another deploy of the app holding its deploy lock or having swapped since this plan was made (rollback may have restored the previous release; the exit stays 1); the recorded preview couldn't be withdrawn; zap.lock couldn't be opened

zap detect

zap detect [--no-cache] [--bootstrap] [--fgro <p>] [--env-file <p>] [config]

The detection half of plan, rendered raw: one line per resource with its mark (ok, drift, absent, or ? for undetermined), its id and kind, and, for drift, what was observed. For validating that a probe reads a real target correctly. Over a host that resolves through a masked reference it keeps no last-run report and ends with an UNRECORDED note.

CodeMeaning
0every resource was read and every reference and pin resolved; drift and absence still exit 0
1a resource marked ?, an unresolved reference, or a lock standing error

zap status

zap status [--fgro <p>] [--env-file <p>] [config]

Offline: evaluates the config, reads the last-run report for the host the target resolves to (detection.md) and the on-disk zap.lock, and never touches the target. It says what the last run saw, never what the target is now; the live signal is zap plan. A referenced host needs its env (--env-file), as for a run. For an exact answer it needs the same env as the run that wrote the report, secrets included: a secret it can't mask reads as "config changed since", never as a false "unchanged".

$ zap status target local (pkg: none, service: none) last run on local: zap apply --wetrun, after its changes ran, at 2026-10-11T21:09:26Z — offline; `zap plan` reads the target live a 0 exit means every declared resource read converged in the last run and its config is unchanged since — not that the target still is converged notes directory converged hello file

Each line is a resource's verdict in that run, with a note when:

NoteMeaning
config changed since; this verdict predates itthe resource's hash no longer matches: it was edited, an ordinary variable it references resolves differently, or its pin moved. When the target itself changed (its package or service manager, home, user), a note names it and every verdict predates it.
depends on a secret; a changed value isn't visible offlineit references a secret
not read (not in the last run)declared, but not in the last run: added since, or there is none
orphaned (read …, no longer declared)in the last run, no longer declared; warn-only

A preview's plan_hash is printed as the plan zap apply --wetrun binds to. The lock standing and an UNVERIFIABLE banner, for the last run or for now, lead the render. With no last run every resource is not read, so status exits 1 unless nothing is declared.

CodeMeaning
0every declared resource read converged in the last run and its config is unchanged since (a secret's value excepted); lock clean; nothing unverifiable
2the last run read drift or absence
1anything unestablished: not read, changed since, undetermined, unverifiable then or now, a lock error, no last run, or a host that keeps none (one behind a masked reference)

zap config validate

zap config validate [--fgro <p>] [--env-file <p>] [config]

Evaluates the config and runs the two gates, nothing else. Prints ok: N resource(s) and exits 0, or the gate's message and exits 2. The fast check for CI and editors: no target, no network, no secrets needed, but a copy of the pinned fgro build (lock.md).

zap lock

zap lock [--check] [--fgro <p>] [--env-file <p>] [config]

The verbs over zap.lock, the ledger for the toolchain pin and the tracked remote files and build sources (lock.md).

zap lock pins the toolchain, the blake3 of the fgro binary it evaluates the config with, re-fetches every tracked source, re-mints every entry, drops orphans, and rewrites the file. All or nothing. It is the only command that evaluates with a binary the lock doesn't pin, and the only one that moves the toolchain pin (lock.md).

zap lock --check first holds the fgro binary to the toolchain pin: with a binary that hashes to something else it reports the pin stale without evaluating the config, so the entries go unchecked. Otherwise it classifies each entry offline as current, missing, stale (the resource changed since the pin), unverifiable (a variable the hash takes by value is unset), or orphaned. Its 1 is a read-only verdict, like plan's held resources; apply, which refuses to act on a stale or unverifiable entry, treats the same finding as an error and exits 2, as does a deploy wetrun's preflight.

CodeMeaning
0zap lock: the lock was written. --check: every entry is current; orphans only warn.
2no toolchain pin (--check); a tracked URL that references an unset or empty variable (zap lock, which then fetches nothing)
1--check: a pin missing, stale, or unverifiable. Either: a fetch or another operational error (all commands)

Both print a note: for each reference the lock addresses by name only, because its name or the form of its value reads as a secret (lock.md).

zap update

zap update [--config <p>] [--fgro <p>] [--env-file <p>] [ids…]

Re-fetches the named resources, or every tracked one, and rewrites each entry whose pin advanced or whose recorded config hash is stale. Each advance is written as it happens, so a later failure keeps the earlier ones. A tracked URL that references an unset or empty variable is never fetched, and that entry fails. The config is --config <path> here, since the positional arguments are ids. A failed entry keeps the code its error has everywhere else: exit 1 if any failure is operational (a fetch, a write), else 2 if any entry failed on an input to fix (an unset reference in its URL or credential, a signature that doesn't verify). It prints the same note: lines as zap lock.

zap version

Prints zap <version>.

Banners

What a pass couldn't establish, or why it didn't act, leads its report:

BannerMeansExit
UNDETERMINEDresources detection couldn't read, each with why, then the changes held behind them, each with what it waits on or would trigger; all held out of the plan1; 2 in a deploy's preflight
UNVERIFIABLEreferenced variables that were unset or empty1; a wetrun refuses with 2
LOCKa tracked pin that is missing, stale, or unverifiable1; apply and a deploy wetrun refuse with 2
UNREVIEWEDa wetrun's plan isn't the reviewed one; on a terminal, Zap asks
REFUSEDa wetrun that didn't execute; its plan becomes the preview2 (1 when a resource is held)
DECLINEDthe confirmation wasn't the plan's hash prefix2
RESIDUALresources that still don't read converged after a wetrun1
UNBINDABLEthe host, connecting user, or (deploy) health domain is behind a masked reference: no plan_hash, not a preview
UNRECORDEDthe host is behind a masked reference: no last-run report kept

Exit codes, all commands

The pattern across commands. The tables list 2 before 1 because 2 is still an established answer and 1 is not:

CodeFamily
0established and fine: converged, deployed, nothing to do
2established and needs a decision: changes pending (for the commands that judge them: apply, status), a refused or declined wetrun, preflight drift, a config error
1unestablished or failed: a held or unverifiable resource, a failed change, a failed step, an operational error

plan, detect, and a deploy dry run report rather than judge: pending changes exit 0 there.

An error prints as error: and its message; a message of several lines is indented under it, and any advice Zap adds follows at the left margin.

Errors raised before a report exists follow the same split.

Exit 2, a problem in your inputs:

  • fgro: none named; a path that isn't absolute; one that is missing, a link, a script or set-ID file, or doesn't hash to the toolchain pin; one that answers in an eval protocol or graph version this zap doesn't read; no toolchain pin; an fgro pin cache under ~/.zap that isn't yours.
  • zap.lock: one that doesn't parse or is newer than this zap reads; a lock stale or unverifiable; a content-hash or signature mismatch.
  • The config: not found; named as its own lock would be (*.lock); it or its directory writable by every user; no project; a package.json on the way up that can't be read or isn't valid; no user the evaluation may run as (a project root owns, a project that is or holds that user's home, a directory on the way it can't search, a group or passwd entry it can't use); eval failure; a schema error.
  • The environment: an env-file error; a ZAP_HOME or HOME unset where needed or not absolute; a state directory that isn't Zap's own; an unresolved reference on a wetrun.
  • A wetrun refused before its first change: a plan that names no one machine, a command that can't take a value its reference resolves to, or a reference that moved while the run was in progress.
  • A deploy wetrun from a checkout with uncommitted changes or untracked files and no --force.
  • A usage error: an unknown flag, or --expect-plan without --wetrun.

Exit 1, a problem in the world: connectivity, a command that can't spawn, a file operation, a fetch, a git error; an fgro that crashed, wrote output that isn't its protocol, or couldn't be spawned; an fgro binary written while Zap hashed it or before it ran, which a retry clears once nothing writes it.

JSON events

--json on a wetrun of apply or deploy streams one JSON object per line to stdout, instead of the text report. A wetrun that ends before its first event (a refusal, a preflight bail, an already-deployed release) leaves stdout empty and prints its text report to stderr. Control and invisible characters in a value are written as JSON \u escapes, so the stream decodes to the same values and is safe to watch. Each event carries an event tag:

eventFieldsWhen
run_startedtarget, wetrun, plan_hash, totalonce, before any change
change_startedresource_id, action (create / update), kinda change is about to run
change_completedresource_id, outputit finished
change_failedresource_id, error, codeit failed; execution stops
change_skippedresource_id, reasonnot run, because an earlier change failed
step_startedstep, namea deploy step is about to run
step_completed / step_failed / step_skippedstep, plus output / error / reasonthe deploy step's outcome
run_finishedsummary (total / completed / failed / skipped counts)once, at the end

A step skipped after an earlier failure has no step_started, but a started step's outcome can be step_skipped too: a readiness check the target doesn't serve, or a rollback whose swap back finds nothing to do (another deploy holds the app's lock, current no longer names the failed release, or names nothing, or the previous release is gone). A rollback with nothing to return to is skipped without starting.

code on a failure is a triage category: transient, permission, not_found, conflict, permanent, or execution_failed (permission includes sudo's own refusals). A file operation Zap makes itself is classified by the error the system returned (a symbolic link or anything else in the way, or a full disk, is conflict; a timeout is transient); a command, or ssh, by its exit code and what it printed. Every value is masked: a resolved secret never appears in an event.

A run that streamed events doesn't print its text report, and the report's warning: lines go with it: the stream carries a warning only where an event holds it. A deploy's rollback is always one: rolled back, failed, or skipped, with nothing to return to (a broken release left live) among the skips. The others, such as a deploy's repairs of an unfinished one or a failed prune, aren't in the stream.

Environment variables Zap reads

VariableMeaning
ZAP_HOMEwhere Zap keeps operator-side state (default ~/.zap): the eval cache, the fgro pin cache, the content cache, last-run reports, deploy previews, SSH multiplexing sockets. An absolute path; empty counts as unset. See lock.md.
ZAP_FGROthe fgro binary that evaluates configs, by absolute path (an empty one is an error, not unset); never looked up on PATH. --fgro overrides it, and is the one that survives sudo. Name a copy you update deliberately, not Gro's build output (install.md).
ZAP_MINISIGNthe minisign binary a build source's verify runs, by absolute path (an empty or relative one fails the verification, saying so); unset, the first minisign in an absolute directory on PATH. Either is refused when it, or a directory on the way to it, is writable by a group or others or owned by anyone but root and you, or it sits in node_modules (resources.md)
ZAP_EVAL_CACHEshadow (default: eval fresh, compare against the cache, report a mismatch) or skip (serve a cache hit whose files are unchanged without running fgro; a change the evaluation's record doesn't name isn't seen: a symbolic link, in node_modules or the project, pointed at other files, or a directory turned world-writable)
ZAP_EVAL_CACHE_LOGset to print eval-cache hit/miss observations

Nothing that shapes a plan comes from Zap's own environment. Values a config needs come from references resolved from the operator environment or --env-file.

Resources #

A Zap config (zap.ts) declares resources: typed descriptions of desired machine state. Zap detects the live state, compares it to the declared resources, and applies the smallest set of changes that converges them. Each resource has a stable id, an optional name, an optional depends_on list, and a kind, set by the builder method you call (it becomes the type in the JSON the config evaluates to).

export default (zap) => { zap.target({local: true}); zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}}); zap.build({ id: 'ghostty', source: {type: 'remote', url: 'https://example.com/ghostty.tar.gz', extract: {type: 'tar.gz', strip_components: 1}}, run: 'zig build -p $HOME/.local -Doptimize=ReleaseFast', output: '$HOME/.local/bin/ghostty', }); };

Resource types

Builder methodTypeWhat it declares
zap.pkgpackagean OS package, through the target's package manager
zap.apt_repoapt_repoan apt source list and its signing key
zap.scriptscripta command gated by an idempotency check
zap.installerinstallera script fetched with curl and run by a shell, gated by a check
zap.userusera Unix user: shell, home mode, SSH keys, sudo
zap.directorydirectorya directory at path, with mode and owner
zap.filefilea file with inline, template, source, or remote content
zap.linklinka symlink from path to target
zap.serviceservicea system service: enabled, started, stopped
zap.databasedatabasea PostgreSQL database and its role
zap.swapswapa swap file in /etc/fstab
zap.firewallfirewalldefault policies and allow rules
zap.verifyverifydetection-time assertions, with no execution of its own
zap.pipelinepipelinean ordered imperative workflow of steps
zap.buildbuilda verified source archive, built and installed

Each section below gives the resource's purpose, its fields, and any structure specific to the type. In path fields, references resolve against the operator environment, and ~ and $HOME resolve against the target's home. Both happen when Zap reads or changes the machine, and the plan holds both as written (env.md).

One resource per thing

Two resources that manage one thing would undo each other on every apply, so a config that declares them doesn't validate; the message names both ids and the thing. What a resource manages:

TypeManages
file, directory, link, swapits path
buildits output and the record beside it
apt_repoits key file and its list file
userthe account, its ~/.ssh directory and authorized_keys when ssh_keys is set, and its sudoers file when sudo is set
servicethe service
databasethe database (several databases may share a role)
firewallthe machine's firewall (a config declares at most one), and under nftables /etc/nftables.nft

A package, a script, an installer, a pipeline, and a verify manage nothing exclusive, so the same package may be declared twice. Two resources at one path conflict even when they declare the same thing, so a directory two recipes both need is declared once. Paths are compared as written, after //, /./, and a trailing / are dropped and $HOME is read as ~; two spellings that only meet on the machine (~/x and /home/ops/x, a .. component, nginx and nginx.service) aren't caught.

URLs

A URL in a config is https:// with a host, and holds no user:password@ (a credential goes in auth, where it is a reference). Any other scheme is refused, http:// and file:// included, when the config is read and again on the resolved value when the URL holds a reference. For the URLs Zap itself fetches (a remote file's, a build source's and its signature's, an apt_repo's key_url), http:// is accepted for a loopback host (localhost, 127.0.0.0/8, [::1]), which is what a local mirror or a test uses. A redirect is followed only to another https:// URL; a fetch that has stayed on loopback may be redirected on loopback. The URL the target fetches (installer.url) is https:// only.

Account names

username, groups, and every owner and group field take an account name, such as 'deploy', not a numeric uid or gid. Zap compares ownership against the name from stat, so a numeric value is rejected when the config is read.

The name must be a portable Unix account name: a lowercase letter or _, then lowercase letters, digits, _, or -, optionally ending in $. That is the useradd default on Debian and Alpine. Anything else (an uppercase letter, a space, /, a comma, a backslash) is rejected there too. The name is interpolated into awk, usermod -aG, a sudoers drop-in, and /home/<name> paths, where a stray character would break out of the form.

package

An OS package, installed through the target's package manager: apt (Debian/Ubuntu) or apk (Alpine). The other managers (pacman, brew) parse but have no backend, so their packages detect as undetermined.

zap.pkg({id: 'ripgrep', from: {apt: 'ripgrep'}});
  • from (required): a map from package manager to package name, such as {apt: 'ripgrep'}. The target's package_manager selects the entry. A name takes no reference; see Package names.
  • check (optional): a custom check, either a path (tested for existence) or a command (exit 0). It defaults to the manager's own query. A check beginning /, ~, or $HOME is a path, and its references resolve; anything else is a command, run as written, and takes no reference. With no check, a package with no entry for the target's package_manager, or a target that declares none, holds (undetermined).

Installs are batched, after an automatic cache-update step. A package that waits on another resource that waits on a package installs in a later batch (batch-install-<manager>-2, …), after that resource. Detection is by existence through the manager's query: a package held back, or installed for any architecture, is installed. A query that fails (the database unreadable, the tool missing) holds the package as undetermined rather than reading it missing.

Package names

A name is one word in the manager's syntax: a letter or a digit, then letters, digits, and + . _ - = ~ : / @, not ending in -. Zap writes it into the manager's command line unquoted, so a space or a shell character is refused. The form decides what reads as installed:

FormManagerInstalled when
nameapt, apkthe query finds it, at any version
name=versionaptit is at exactly that version, epoch and revision included. apt upgrades to it and refuses to downgrade to it, which fails the step.
name/suiteaptname is installed; the suite chooses where a first install comes from
name:archaptit is installed for that architecture; a package built for every architecture (all) counts as the target's own
name=version, name~versionapkthe installed version matches, as apk compares them
name@tagapkname is installed; the tag chooses where a first install comes from

A version and a suite together name no package apt knows. Refused, because the query would never find the package installed or the form isn't one word:

  • apt: an empty version (name=) or architecture (name:); name:any and name:native, which apt resolves to an architecture and dpkg records no package under; name:all, which apt reads as the native architecture while dpkg records all only for a package built for every one (name the architecture, or none); an architecture that isn't one word of lowercase letters, digits, and -.
  • apk: name>=1.2, !name, .name; a version operator with nothing after it (name=, name~, name==, name@tag=), which apk's query reads installed whether or not it is and apk add refuses.

A trailing + is part of the name, since Debian has packages named so (memtest86+). apt's own name+ install marker on a package with no + installs it but never reads installed, so write the name without it.

apt_repo

An apt repository: a GPG signing key plus a sources-list entry. Zap installs the key, writes the .list file, and injects signed-by=<key_path> into the source line automatically, so the key and the repo can't drift apart.

zap.apt_repo({ id: 'pgdg', key_url: 'https://www.postgresql.org/media/keys/ACCC4CF8.asc', source: 'deb https://apt.postgresql.org/pub/repos/apt bookworm-pgdg main', });
  • key_url (required): the URL Zap fetches the GPG signing key from (URLs). It may carry a reference.
  • key_blake3 (optional): the key's pin, the blake3 of its bytes, 64 lowercase hex. Without it the pin is tracked in zap.lock (lock.md).
  • source (required): the deb or deb-src source line. signed-by= is injected from key_path.
  • key_path (optional): where the key is stored. It defaults to /usr/share/keyrings/<id>.asc.
  • list_file (optional): the .list file path. It defaults to /etc/apt/sources.list.d/<id>.list. The list is installed whole, mode 644.

key_path and list_file take no reference; key_url may carry one. Each of the two paths is absolute and holds no whitespace and none of ", $, a backtick, or \.

source is written through a shell with /etc/os-release sourced, so it can reference the distro's variables for portability. For example, deb … $VERSION_CODENAME main resolves to bookworm, noble, and so on, on the target. key_path is written into that line, so the shell reads it too. list_file is passed literally; key_url is fetched by Zap on the operator's machine and never reaches the target. The detector resolves source the same way, so a templated line still converges. Because the shell reads the line, source takes no reference.

The key is pinned content, like a remote file's. Zap fetches it on the operator's machine, checks the bytes against the pin, and installs those bytes whole at key_path, mode 644, owned by root:root: staged beside the key, checked against their SHA-512, and renamed over it, so the live key is only ever replaced by the pinned bytes. A fetch whose bytes aren't the pin's fails the change before anything on the target is touched, naming both digests:

content hash mismatch for https://www.postgresql.org/media/keys/ACCC4CF8.asc: declared <pin>, fetched <blake3 of what was served>

Detection reads the key and compares its blake3 with the pin: a key that isn't the pinned bytes drifts (signing key differs from its pin), and the apply installs the pinned one. A rotated upstream key is not drift until the pin moves: zap update <id> fetches the key again and advances the pin, which shows in the lock's diff. A key with no pin yet is held (undetermined) by the read-only commands, a dry apply mints its pin, and a wetrun records it in zap.lock before it runs, as for any tracked content. A key_path that is a symbolic link, a hard link, or not a regular file, or a key over 1 MiB, is held too: its bytes aren't compared.

script

An imperative command guarded by an idempotency check. Zap runs check first. An exit code of 0 means the script is already applied and run is skipped. Otherwise run executes.

zap.script({ id: 'enable-lingering', check: 'loginctl show-user $USER | grep -q Linger=yes', run: 'sudo loginctl enable-linger $USER', });
  • check (required): a command whose exit 0 means "already applied", which skips run.
  • run (required): the command run when check fails.
  • cwd (optional): the working directory for check and run. An absolute path, taken as written: a reference is rejected when the config is read, and a ~ or a relative path by the structural gate.
  • env (optional): extra environment for the whole of check and run. Values resolve references and can hold any byte but NUL (and the two characters Zap marks a reference with, U+FDD0 and U+FDD1), so env is how a script takes a value with a ' in it, or a secret. A name can't begin with __zap_.

The check carries the whole idempotency contract. Make it precise, or the script runs on every apply.

cwd and env apply to both commands, the same way on a local target and over SSH: Zap enters cwd, exports env for the whole command (every command of a compound sees it), then runs your text on a line of its own. The plan shows them, and the check, above the command:

[+] migrate (script) cwd: /srv/app env: DATABASE_URL={env:DATABASE_URL} env: MODE=prod check: test -f .migrated > ./migrate.sh

A check that can't enter cwd reads as not applied, like any failing check, so a script whose cwd another resource creates converges in one apply. A run that can't enter it fails the change with nothing run. A sudo inside either command resets the environment by default, so env doesn't cross it.

installer

Software installed by running a downloaded script, the curl … | sh pattern, gated by a check so it runs only once. Zap fetches the script whole first and hands it to the shell only if the fetch succeeded and the script isn't empty, so a failed download fails the step and nothing runs.

zap.installer({ id: 'deno', url: 'https://deno.land/install.sh', check: '~/.deno/bin/deno', });
  • url (required): the install script's https:// URL (URLs), piped into shell.
  • check (required): a path, absolute or beginning with ~ or $HOME, tested for existence to confirm installation.
  • shell (optional): the command the script is piped into (default sh). It is one line, shown in the step as written and read by the target's shell like a script's run, so bash -s -- --yes passes arguments.

An installer executes remote code on the target, so prefer a pinned, trusted URL. For content that should be verified and pinned, a build or a file with remote content is the stronger choice.

user

A Unix user account. Zap creates the account if it doesn't exist, with useradd -m -d /home/<username>, so the home is /home/<username> whatever the distro's default. Zap then detects and reconciles each managed attribute independently. An attribute left unset is unmanaged: Zap neither sets nor reverts it.

Zap manages a user's home as /home/<username>: the paths home_mode and ssh_keys act on are derived from the name, not read from the machine. The cases where that would be wrong fail loudly instead:

  • A user with username: 'root', whose home is /root, is rejected by the structural gate before any run, whatever it declares. Manage root's files with file and directory resources.
  • An existing account whose home in /etc/passwd is anything else, or that has none there, is held (undetermined) when the user declares home_mode or ssh_keys, and the reason names the home it found:deploy's home in /etc/passwd is /srv/deploy, not /home/deploy: zap manages a user's home as /home/<name>, so its `home_mode` and `ssh_keys` would miss the account's own

    An account that doesn't exist yet has no /etc/passwd home to hold it: Zap creates it.

  • What is at /home/<username> is read too, without following a link, whether the account exists or not. useradd leaves a directory already there as it was, owned by root typically, and copies no skeleton files into it:
    • when Zap creates the account, the creation gives the directory itself, not what it holds, to the user and its login group after useradd: sudo chown -h -- 'deploy:' '/home/deploy', a no-op when useradd made the directory;
    • for an existing account, a directory the user owns or root owns is as declared and keeps its owner, so a home sshd's ChrootDirectory needs root to own stays root's;
    • a directory owned by any other uid, a live account's or one no account has, holds the user, existing or not;
    • a symbolic link at /home/<username> holds the user and is not followed: Zap acts through no link it didn't make, so a layout that links the home elsewhere declares neither home_mode nor ssh_keys; anything else that isn't a directory holds too;
    • a /home that is missing, or that the connecting user can't search, holds the user, naming /home, rather than reading as nothing there.
    /home/deploy is owned by uid 1002 (deploy's uid is 1001); chown it to deploy, or remove it: zap manages a user's home as the directory /home/deploy, the user's own or root's

    A user that declares neither home_mode nor ssh_keys isn't read for its home.

zap.user({ id: 'deploy', username: 'deploy', shell: '/bin/bash', ssh_keys: {copy_from: 'root'}, sudo: 'nopasswd', });
  • username (required): the Unix username to manage. It must be a portable account name; see Account names. groups entries follow the same rule.
  • shell (optional): the login shell, such as /bin/bash. The system default applies when unset. It takes no reference.
  • home_mode (optional): the octal mode for the home directory, such as 711.
  • ssh_keys (optional): the SSH authorized_keys, as either {copy_from: '<user>'} or {keys: ['ssh-ed25519 …', …]}, never both: an object with both is rejected before any run. A keys list needs at least one key, and no entry may be blank. See SSH keys.
  • sudo (optional): the user's sudo grant. See Sudo.
  • groups (optional): supplementary groups the user must belong to, such as ['docker', 'postgres']. It is additive: listed groups are added (usermod -aG) and groups not listed are left untouched. The primary and system groups are never stripped. Names only, like owner.

SSH keys

ssh_keys is authoritative. Zap writes authorized_keys to exactly the declared set and detects drift by comparing the file's content. A key added out of band is removed on the next apply, and a rotated or removed key is applied again.

A key is installed as written and takes no reference; one is rejected before any run. An entry is one key on one line: an entry that ends in a line break doesn't validate. A config reads no file but its imports, so for keys that differ per environment, have the config import them from a module in the project (export const OPS_KEYS = ['ssh-ed25519 AAAA… ops'];).

For copy_from, the source user's authorized_keys is the desired content. If it can't be read, the user is applied again and does not read converged.

Zap gives the account its ~/.ssh (mode 700, owned by the user; a symlink there is refused, not followed) and then writes authorized_keys as that user, mode 600, so nothing the user plants in its own .ssh can redirect the write anywhere the user couldn't already write. A copy_from source is read as the source user at apply. Other files in .ssh are left as they are.

Setting ssh_keys also normalizes the account's /etc/shadow password field to *, which means no password and not locked. This is authoritative too: any other value drifts and converges back to *. That covers:

  • a ! lock. useradd leaves a passwordless account locked, and Alpine's sshd refuses pubkey login to a locked account ("account is locked").
  • an empty field, which allows passwordless login and is a security hole;
  • a set password.

Sudo

  • 'nopasswd' or 'password' grants sudo for all commands.
  • {mode: 'nopasswd' | 'password', commands: ['<sudoers spec>', …]} restricts the grant to the listed commands. A spec is written into the file as declared and takes no reference. The list can't be empty, and a spec can't be blank or hold a control character (a tab counts): each is rejected when the config is read. For a grant of every command, write the mode alone. A spec is sudoers text, so a , in one ends a command and begins another.
  • false removes the user's sudoers file.
  • true is rejected at parse. It grants nothing, and would otherwise read as a removal.

The sudoers file is installed at mode 440 only after visudo -c accepts it; a file visudo rejects is never installed, and the old one stays.

directory

A directory at path, with optional managed permissions.

zap.directory({id: 'app-dir', path: '/opt/app', mode: '755'});
  • path (required): the directory path, absolute or under ~. It may carry a reference, and the value it resolves to is held to the same rule.
  • mode (optional): the octal mode, such as '755', 7777 at most.
  • owner (optional): the owner.
  • group (optional): the group.

Zap makes the directory and any missing parents. Each one it makes, the directory itself included, takes the owner and group of the nearest directory that was already there, and the mode a plain mkdir gives; then the declared mode, owner, and group are set. So an owner or group left unset is inherited from above, and an existing directory keeps its own. Anywhere below a directory with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep, on the path as written or where its links lead, nothing is inherited: what Zap makes there is the writer's. On a local target, directories that take another account's owner, or a group you aren't in, are made through sudo.

With a managed mode, owner, or group, a symlink at path reads as drift and fails the change: Zap sets a directory's attributes on the directory itself and never through a link. Use link for a symlink.

file

A file with managed content and optional permissions. Content comes from one of these sources, selected by content.type: inline, template, source, or remote.

The file's parent directory is created automatically if missing, like mkdir -p: each directory made takes the owner and group of the nearest one that was already there (the writer's, below a directory anyone may write, as in directory), and the mode a plain mkdir gives; an existing one is left as it is. A separate directory resource is only needed when the directory itself wants a managed mode or owner.

A file is a regular file, written whole: Zap stages the content under a new name beside it (.zap.XXXXXX, mode 600 until a declared mode is set), checks over SSH or sudo that the bytes that arrived are the bytes it sent, sets the declared owner and mode on that staged file, and renames it into place. So the file never exists at its path with partial content or the wrong mode, a mode or owner drift rewrites it, and a symlink at the path is replaced by the file, not followed (a symlink to a directory fails the change on Alpine; with a managed mode, owner, or group, a symlink there reads as drift). Use link for a symlink.

A mode, owner, or group the file leaves unset is kept: a rewrite gives the new content the owner and group of the file it replaces, and its mode without any set-id or sticky bit. A new file takes its directory's owner and group, and the mode a plain create gives (0666 less the writer's umask). A symlink at the path, or a file with a second name (a hard link), keeps nothing; the file that replaces it is the writer's. Nothing is kept anywhere below a directory with its sticky bit set or that every account may write (/tmp, /var/tmp, /dev/shm), however deep, on the path as written or where its links lead: anyone could have put what is there, or made a directory or a link on the way, so the file is the writer's.

A mode is never kept from a file with an extended POSIX access list (ACL): its mode bits show the list's mask rather than its group's, and the rewrite carries no list, so a kept mode would hand the group what the list gave one account. A file that leaves mode unset over such a file fails the change, with the file as it was, asking for a declared mode. Zap reads the list with getfacl, else from the + GNU ls -l prints after the mode; a target with neither, such as stock Alpine (BusyBox ls, no acl package), can't show one, and there the mode is kept. On a local target, keeping an owner or group you can't give, such as another account's file or a new file in a directory root owns, goes through sudo.

Declare mode, owner, and group on any file whose content the directory's owner or group must not read. Without them, a file written into a directory another account can write can end up readable by that account: it takes that account's directory's owner and group, or the owner, group, and mode of the file that account planted at the path. A declared owner and group don't replace a planted file's mode, so without a mode the content can land readable by everyone (security.md).

zap.file({ id: 'motd', path: '/etc/motd', content: {type: 'inline', content: 'Managed by zap.\n'}, mode: '644', });
  • path (required): the target path. It may carry a reference and ~.
  • content (required): the content source. See content.
  • mode (optional): the octal mode, such as '644'.
  • owner (optional): the owner, as a user name and not a numeric uid. See Account names.
  • group (optional): the group, as a group name and not a numeric gid.
  • triggers (optional): ids of service resources notified when this file changes; naming any other kind doesn't validate. This is the push side of the trigger model; see service.
  • validate (optional): a command that must exit 0 after the write, such as sudo nginx -t (exported as NGINX_VALIDATE from recipe_nginx.ts). See Validated writes.

Validated writes

With validate set, apply snapshots the file (its bytes, mode, and owner, read through one open descriptor and held in memory: as root over SSH, and locally as you, escalating when refused), writes, then runs the command against the file in place. A symlink at the path, anything there that isn't a regular file, and a file over 16 MiB can't be snapshotted, so the change fails before the write. If the check fails, the snapshot goes back as a regular file with the owner and group the old content had, and its mode without any set-id or sticky bit the declared mode doesn't carry; a file that didn't exist is removed; and the change fails. So any service the file triggers is never reloaded onto the bad config. A write that fails before installing leaves the file as it was, and restores nothing.

Checking in place is what lets it cover a file some larger config includes: nginx -t can't check a site file on its own.

zap.file({ id: 'nginx-config', path: '/etc/nginx/sites-available/example.com', content: {type: 'inline', content: site}, validate: NGINX_VALIDATE, });

What it doesn't cover:

  • One file at a time. Each file is checked against whatever else is on disk. An edit that has to land in two files at once (renaming a zone one file defines and another uses) fails the first file's check and rolls it back. Stage the edit across applies: add the new name, switch the user, drop the old.
  • Not yet active. The check only sees what the parent config loads. An nginx site in sites-available/ isn't checked until its sites-enabled/ link exists, so on a fresh host the first real check is the reload.
  • The write-to-restore window. The rejected content is on disk for one command's round trip. A run killed inside it leaves that content in place, and detection reads it as converged, since the bytes match what was declared.

The last two, plus hand edits on the host, are what a verify running the same command closes. It is checked again on every plan. A config the running service couldn't reload onto then shows up as a pending change and does not read as converged, and zap deploy's preflight refuses to ship over it:

zap.verify({ id: 'nginx-config-valid', ...nginx_config_verify(), // {assertions: [{type: 'command', run: NGINX_VALIDATE}]} depends_on: ['nginx-service'], });

validate is apply-time behavior, not desired state: detection doesn't read it, and changing it alone plans no change. It is still part of the declaration: the last-run report's hash covers it, and so does plan_hash whenever the file has a change in the plan. The lock's config_hash leaves it out. It is for text config (inline, template, source), and is rejected on remote content and when blank.

content

The content source is discriminated on type.

inline: literal content, written verbatim.

  • type (required): 'inline'.
  • content (required): the file's content, installed as declared. It takes no reference.

template: a {{key}} template with per-variable substitution.

  • type (required): 'template'.
  • template (required): the template body, with {{key}} placeholders. A placeholder is a variable's name between the braces and nothing else; braces around anything that isn't a name ({{ .Values.port }}) are text.
  • vars (optional): the values substituted for each {{key}}, after a reference in a string value resolves. A value that isn't a string is substituted as its compact JSON text and takes no reference at any depth; pass the value as a string var of its own. A reference can't stand for the whole vars map. A value with no kind is substituted as text, so it has to be safe where it lands: in a file a shell or systemd's EnvironmentFile= reads, a value holding a ' ends its quoting.
  • kinds (optional): the kind of value a variable must render to, by its name. Each key names a variable in vars. A variable not named here declares no kind: a reference in its value is still held to the text rule, and a value written out is substituted unchecked.

Placeholders and variables match one for one: a {{name}} whose name is letters, digits, and _ (not starting with a digit) and that has no variable of that name doesn't validate, and neither does a variable no placeholder names, so a misspelled name is caught in either direction. The body is substituted in one pass and a value is never read again, so a value may hold {{…}} as text; that is also how a literal {{name}} is written.

A reference's value can't hold a line break (CR or LF) or a NUL: the files a template makes are read a line at a time, and a line break in a value would add a line of its own (another variable in an environment file, another directive in a config). Detection holds the file and names the variable, and a wetrun is refused before anything changes.

Within its line, what a value may hold is the file's grammar: a ; or a } in an nginx server_name {{domain}} ends the directive or the block. A kind holds a variable's whole rendered value to a form whose alphabet has none of that:

KindA value is
textany one line: no line break, NUL, or reference delimiter; every kind keeps this rule, and a reference's value is held to it with no kind declared
hostnamedot-separated labels of ASCII letters, digits, and -, each 1 to 63 bytes, no leading or trailing -, at most 253 bytes; no trailing dot, wildcard, or _
porta decimal number from 1 to 65535, with no sign or leading zero
pathan absolute path whose names are ASCII letters, digits, ., _, -, +, and @, with no empty, ., or .. name
emaila local part of ASCII letters, digits, ., _, %, +, and - (no leading, trailing, or doubled .), an @, and a hostname
env_valuea value inside single quotes in an env file: text with no '
zap.file({ id: 'nginx-site', path: '/etc/nginx/sites-available/app', content: { type: 'template', template: site, vars: {domain: zap.env('DEPLOY_DOMAIN'), port: 4040}, kinds: {domain: 'hostname', port: 'port'}, }, });

A value written out is checked when the config is read. One carrying a reference is checked once it resolves: detection holds the file, and a wetrun is refused before anything changes, naming the variable and its kind, never the value. The nginx site and env recipes return their templates whole, with the kinds of the variables they place (recipes.md).

source: content copied from a file already on the target at apply time.

  • type (required): 'source'.
  • path (required): a path on the target, absolute or under ~. It may carry a reference.

The source is read as root over SSH, byte for byte, so a binary file copies whole. It must be a regular file of its own at that path: a symbolic link there is never read through, and neither is a directory, a device, or a fifo. Detection holds the resource on any of them, and apply refuses them with nothing written. The bytes are held in memory while they are written, so a source larger than 16 MiB is refused at apply.

remote: content fetched from a URL, content-addressed and verified the same way a build source is.

  • type (required): 'remote'.
  • url (required): the fetch location (URLs). It may carry a reference, resolved when Zap fetches; how the lock addresses it is in lock.md.
  • extract (optional): how the bytes are materialized. {type: 'none'} is the default; {type: 'tar.xz' | 'tar.gz', strip_components?: N} extracts an archive. strip_components lives inside the tar form (beside none it is rejected), and strips leading path components: 1 flattens a tarball's single top directory.
  • auth (optional): a bearer credential for a private byte host, resolved at the IO boundary and never hashed. See Remote auth.
  • blake3 (optional): a vendored content pin. Present, the file pins itself; absent, it tracks through zap.lock. See lock.md.

How a remote file is detected. An unextracted file by digest against the pinned bytes, an extracted one through its .zap_remote_hash sentinel; detection never fetches. See detection.md.

Placement and ownership. Placement and extraction escalate as needed: a none placement or an archive extract into a root-owned destination (such as /opt/zig) runs under sudo. When extract produces a directory tree, a declared mode, owner, or group applies to the top-level destination only (a symlink there holds the resource, and nothing is extracted through it). The extracted contents below it belong to the extracting user, which is root when the extract escalated, whatever owners the archive records, and carry no set-id bit on a file and no group or world write bit. The archive is extracted into a private directory beside the destination, which then takes the destination's name; a failed extraction leaves the destination as it was. A new pin replaces the old tree whole, and the new one gets the owner, group, and mode the old one had (without a set-id or sticky bit) before it takes the name, so what the resource leaves unset there is kept, and a failure to set them leaves the old tree in place. Below a directory with its sticky bit set or that every account may write, nothing is kept, as for a file: the new tree is the extracting user's, at the mode mkdir gives it. A replaced tree that has a POSIX access list (ACL), a default one included, fails the change with the old tree in place, whatever mode declares; remove the list (setfacl -b) to let Zap replace it. The archive never sets the destination's mode, whatever it records for its top directory: a new destination keeps what mkdir gives it. Zap replaces only a tree it extracted (one holding its sentinel) or an empty directory; a destination holding anything else holds the resource (undetermined), and nothing is extracted into it. If the whole tree needs a specific owner, apply it with a script running a recursive chown.

The swap needs room for the old and the new tree at once, and the archive sits on the target three times while it extracts: the upload in a private directory under /tmp, the copy the extraction checks and reads, on the destination's parent filesystem, and the tree it extracts. A run that is killed can leave a mode 0700 .<name>.zap-XXXXXX directory of the extracting user's (root's, when it escalated) beside the destination; killed between the two renames, it holds the old tree. One is also left, empty, where an account that can write the parent renamed it. The staging name is 12 bytes longer than the destination's, so a destination name within 12 bytes of the filesystem's NAME_MAX can't be extracted.

Remote auth

For a private byte host, attach a bearer credential. Its token is typically a reference, resolved on the operator side at fetch time. It is never written to the content hash, and an empty resolution is an error, not a bogus request.

auth: {scheme: 'bearer', token: zap.env('API_TOKEN')}

A symbolic link at path pointing to target.

zap.link({id: 'vim', path: '~/.local/bin/vim', target: '/usr/bin/nvim'});
  • path (required): the symlink path. It may carry a reference and ~.
  • target (required): what the link points to, resolved the same way.

A regular file sitting at path reads as absent and is replaced by the link. A directory there also reads absent, but the step fails rather than putting the link inside it: move the directory, or point the resource at another path.

service

A system service managed through the target's service manager (systemd or OpenRC). It controls whether the service is enabled at boot and whether it is running, and it can restart or reload in response to other resources changing. That is the pull side of the trigger model; file's triggers is the push side.

zap.service({id: 'nginx', service: 'nginx', running: true, reload_on: ['nginx-conf']});
  • service (required): the service name, as the target's service manager knows it. It takes no reference, and is one word: a letter or a digit, then letters, digits, and _ . @ : - (nginx, getty@tty1.service). Zap writes it into the manager's command line unquoted, so a systemd name escaped with a backslash isn't accepted.
  • enabled (optional): whether the service is enabled at boot (default true).
  • running (optional): whether it should be running now. Omit it to leave runtime state unmanaged. On OpenRC, starting a crashed service (its daemon died while it was marked started) first resets it with rc-service <svc> zap, since a plain start of a crashed service does nothing. OpenRC reads a service as crashed when the process its pidfile names isn't running, so a daemon still running under a stale pidfile gets a second copy beside it.
  • restart_on (optional): resource ids whose change triggers a restart of this service: systemctl try-restart on systemd, rc-service --ifstarted <svc> restart on OpenRC.
  • reload_on (optional): resource ids whose change triggers a reload of this service. That is try-reload-or-restart on systemd and rc-service --ifstarted <svc> reload on OpenRC, where a service that can't reload fails the step.

A trigger acts only on a running service. A service that isn't running is left as it is, and starts on the new state when something starts it, so a unit file written on a first deploy restarts nothing before its binary is there. A service converged but for its trigger runs the trigger alone (after systemd's daemon-reload), never its enable or start again. A trigger on a service declared running: false (its restart_on / reload_on, or a file's triggers naming it) is a structural error: it could never act.

The target must declare a service_manager. The structural gate rejects, in every command, a config with service intent on a target that declares none, and doesn't guess systemctl on a host that may run OpenRC. Service intent is any of:

  • a service resource;
  • a file with triggers;
  • deploy.service.

A declared manager with no backend (launchd) parses. Its services detect as undetermined, and their verbs plan as notes, not commands.

Pending triggers

A trigger fires when one of its sources changes in the current plan. On its own, a failed reload would be lost: the next plan reads the source as converged (its bytes match) and the service as running (on the old config).

So Zap keeps a record on the target. Before the first source of a triggered service runs, apply writes a marker to /var/lib/zap/pending/<service> holding the owed action. It removes the marker only after the service's own change completes. If the reload fails, or the run dies anywhere in between, the marker stays, and the next plan fires the action again:

[~] nginx-service (service) (pending reload from an earlier apply) > sudo systemctl daemon-reload > sudo systemctl try-reload-or-restart nginx

A pending restart outranks a reload fired this run. The markers are read live, as root, on every plan for a config that declares a trigger, and a marker for a service the config doesn't manage is reported as a warning. A marker directory that can't be read fails the plan rather than reading as owing nothing. Zap gives /var/lib/zap and pending/ mode 755 and each marker 644, whatever the umask. The service's change clears its marker whether or not the service was running: a stopped service owes nothing, since it starts on the new state.

Zap keeps its state in /var/lib/zap, and refuses the declarations that would manage it. A structural error is any resource whose path is at or under it as written (. and .. taken as written), a home-rooted path with a .. component, a link whose target is /var/lib/zap, under it, or a directory above it (/, /var, /var/lib, so no path below the link leads there), a link placed at one of those directories above it (which it would replace), and a deploy.path there. A path or link target that reaches it only once a reference or ~ resolves holds the resource in detection and is refused by the wetrun. What isn't a declared path isn't checked: the commands a script, pipeline, installer, or build runs can write anywhere, and a symbolic link already on the target that leads into /var/lib/zap is followed like any other directory link.

The record is written first, so the guarantee is at-least-once. A run that fails after the marker but before any source changed fires one reload or restart it didn't strictly need. It never drops one it did.

A held service, whether detection can't read it or it is held behind another, gets no marker, so each source whose change would trigger it is held too, with the reason "would restart (or reload) the service, which is held". Running the source alone would owe a restart nothing records. The next plan that can run the service plans both.

database

A PostgreSQL database and its owning role. Zap creates the role and database idempotently, sets the database's owner back to the role when it has drifted, and manages the declared pg_hba.conf auth rules. It never restarts PostgreSQL, so the target needs no service_manager for it.

zap.database({id: 'app-db', engine: 'postgresql', database_name: 'app', username: 'app'});
  • engine (required): 'postgresql'.
  • database_name (required): the database to create.
  • username (required): the role to create, which owns the database.
  • auth (optional): the pg_hba.conf rules. See Database auth.

Both names, and each auth.hosts entry, are written into a pg_hba.conf line as they are, so none may hold whitespace, #, ", ,, or a control character. A name doesn't begin with +, @, /, or -, a database isn't named with a word pg_hba.conf reads as a keyword in that column (all, sameuser, samerole, samegroup, replication), and a role isn't named all. The rule holds for the value a reference resolves to as well: one that breaks it holds the resource.

Both identifiers are taken literally and never shell-expanded. They accept references, resolved on the operator's machine. A dev config that wants "the role matching the OS user" on every machine declares username: zap.env('USER'). A shell-style username: '$USER' would never resolve.

Database auth

  • hosts (required in auth): the client addresses a rule admits, each an IP address with its prefix length ('127.0.0.1/32', '::1/128'), at least one. A keyword (all, samehost, samenet) or a host name is refused, as written and as a reference resolves. A database with no auth writes no rule.
  • method (optional): the pg_hba auth method of those rules (default 'scram-sha-256'). A rule Zap writes is a plain TCP host line with no options, so a method that can't work on one is refused: 'trust', since a trusted TCP rule lets any account on the machine, any service among them, connect as the role with no credential; 'peer', which PostgreSQL accepts only on a Unix-socket (local) line, the route a database with no auth already takes; 'cert', accepted only on a hostssl line; 'ldap' and 'radius', which need options Zap doesn't write; and 'sspi' and 'bsd', which PostgreSQL doesn't build on Linux.

With no auth, the role is reached the way the stock pg_hba.conf allows a local account: over the Unix socket, by the OS account of the same name (local all all peer on Debian, and on Alpine once alpine_postgres_setup has initialized the cluster). A service running as that account connects with postgres://<role>@/<database>?host=/run/postgresql and needs no password.

Each rule is a host line in the pg_hba.conf the server reports. A missing rule is inserted once, above the first host-type record (indented ones included), or appended when the file has none, so it takes precedence over the rules already there. Detection only asks whether each rule's line is present, anywhere in the file: the rest of the file isn't judged. A rule already there below a broader one that matches first (a host all all 0.0.0.0/0 trust, say) reads present, though the earlier line decides those connections, and a trust line, a duplicate, or any other rule of the file's own is neither reported nor removed. Review a pg_hba.conf Zap didn't write. The rewrite runs as the postgres account, which must own the file and be able to write its directory, as a stock install has it; a pg_hba.conf owned by anyone else is left alone and the step fails saying so. The file is replaced by rename, keeping its group and mode; if the rewritten copy isn't the original plus that one line, Zap leaves the file alone and the step fails. A rule Zap added is put in force by reloading the server's configuration (SELECT pg_reload_conf(), as postgres); a rule already there reloads nothing. Before the reload the step reads pg_hba_file_rules (PostgreSQL 10 or later; on an older server the step fails), which parses the file on disk, and fails without reloading if any line of it has an error. The reload is only a signal, so after it the step waits, up to 30 seconds, for pg_conf_load_time() to move, read from a new connection each second; once it moves, every new connection, a dependent's included, sees the rule. A postgresql.conf the server can't parse keeps that time from moving, so the step fails then too, saying so. A reload the server doesn't confirm, a file it can't parse, or a load time that doesn't move fails the step with the rule in the file, and the next run's plan reads it present and reloads nothing: fix the file the server can't parse, then reload by hand (sudo -u postgres psql -c 'SELECT pg_reload_conf()') once the server can. Zap reads only that one file: a rule inside an included file isn't seen, and an include above the first host line still orders ahead of Zap's rule.

swap

A swap file, recorded in /etc/fstab.

zap.swap({id: 'swap', size: '2G'});
  • size (required): the swap size, such as '2G', used when the file is created. It is not compared afterwards: resizing means removing the file by hand first.
  • path (optional): the swap file path (default /swapfile): absolute, with no whitespace, since it is a field of an /etc/fstab line. It is not ~-expanded and takes no reference.

A swap file that drifted is repaired part by part: an active one isn't allocated, formatted, or enabled again, and one already in /etc/fstab isn't listed again, so an active swap file missing from /etc/fstab is only listed. One that isn't active is allocated again before it is formatted, so a short file a full disk left behind is made whole.

Before the file is allocated, formatted, and enabled, as root, its path is checked: a symbolic link there is refused, since mkswap would format whatever it points at, and so is any directory on the way to it, up to /, as declared and as it resolves, that root doesn't own or that a group or other account can write, where someone else could put a link at the path, or re-point a directory above it, between the check and the format. The refusal names the path, the directory, and the reason, and nothing is made.

Adding the entry rewrites /etc/fstab whole: the table as it reads plus the entry, installed by rename with the table's mode kept and root as its owner. A table the connecting user can't read fails the step with nothing written, and so does one with a POSIX access list (ACL), whose mode can't be kept (file); remove the list (setfacl -b /etc/fstab) to let Zap add the entry. An edit someone else makes between the read and the rename is lost, and a symlinked /etc/fstab is replaced by a regular file.

firewall

Firewall configuration: default incoming and outgoing policies plus a list of allow rules. There are two backends, ufw (Debian/Ubuntu) and nftables (the native backend on Alpine).

Allow rules are additive. Zap asserts that each declared rule is present and that the default policies match. A rule added out of band is neither detected nor removed.

zap.firewall({ id: 'fw', backend: 'ufw', allow: ['OpenSSH', {port: 443, proto: 'tcp'}], });
  • backend (required): 'ufw' or 'nftables'.
  • allow (required): the allow rules. See Allow rules.
  • default_incoming (optional): the default incoming policy, 'allow', 'deny', or 'reject' (default 'deny').
  • default_outgoing (optional): the default outgoing policy, same values (default 'allow').

Allow rules

Each entry in allow is either a named ufw profile (a string, such as 'OpenSSH', which takes no reference and can't begin with -) or a port and protocol object:

  • port (required): the port number, 1 to 65535.
  • proto (optional): 'tcp' (default), 'udp', or 'any'.

Backends

ufw applies ufw default … policies and a ufw allow per rule, then ufw --force enable. Named application profiles ('OpenSSH', 'Nginx Full') and port rules both work.

nftables installs a single declarative inet filter ruleset at /etc/nftables.nft (whole, mode 600) and loads it with nft -f.

  • The ruleset accepts established connections and loopback before the default-drop policy, so applying it never severs the live SSH session.
  • It is scoped to its own table. It does not flush ruleset, so other tables such as fail2ban's are untouched.
  • nftables has no application profiles. Use port rules only; a named profile is a config error.
  • Boot persistence is the separate nftables service. Declare a service resource (service: 'nftables', enabled: true) alongside the firewall, which loads the same /etc/nftables.nft at boot. This companion service is required: the structural gate rejects an nftables firewall with no enabled service: 'nftables' in the same config, because a firewall that silently vanishes on reboot is a security regression. The alpine_web_firewall recipe bundles both.

For both backends, a status probe that fails (exits nonzero), or an inactive ufw, detects as absent (needs setting up) rather than holding; a probe that times out, or whose batch fails, still holds. This is the fresh-host case, where the probe runs before the same config installs ufw or nftables. See detection.md.

verify

Assertions checked at detection and again on a wetrun. A verify resource executes nothing of its own. It checks that conditions hold and, if any assertion fails, becomes a pending change; on a wetrun its assertions run again at its place in the dependency order, and one still failing fails the run there: changes ordered before it have already run, and the rest are skipped. Use it as a guard rail, ordered with depends_on ("this token file must exist before we proceed"). Like every resource, it is checked again on every pass.

zap.verify({ id: 'token-present', assertions: [{type: 'path_exists', path: '/home/deploy/.zap/api_token', file_type: 'file', not_empty: true}], });
  • assertions (required): the assertions to check. All must pass.

Assertions

Each assertion is discriminated on type.

path_exists: assert a path exists, optionally with a required type, mode, owner, or non-emptiness.

  • type (required): 'path_exists'.
  • path (required): the absolute path to check, as written: it takes no reference, and a ~ or relative path is rejected.
  • file_type (optional): the required type, 'file' or 'directory'.
  • mode (optional): the required octal mode; 0644 and 644 are the same.
  • owner (optional): the required owner, as a user name and not a numeric uid. A link at path is followed: the type, mode and owner are its target's.
  • not_empty (optional): whether the path's size must be above zero (test -s, default false). A directory's size depends on its filesystem, so this doesn't reliably check that a directory has entries.

command: assert a command exits 0, optionally with a substring in its stdout.

  • type (required): 'command'.
  • run (required): the command to run.
  • expect (optional): a substring that must appear in stdout.

pipeline

An ordered, imperative workflow of steps run sequentially in declared order. Reach for it when a procedure really is multi-step and doesn't map onto a single declarative resource.

A pipeline has no state to converge to. So it runs on every wetrun, a dry run of a config that declares one always has a pending change (exit 2), and zap status always reads it absent.

zap.pipeline({ id: 'migrate', steps: [ {id: 'backup', run: 'pg_dump app > /tmp/app.sql'}, {id: 'apply', run: './migrate.sh'}, ], });
  • steps (required): the pipeline steps. See Steps.

Steps

  • id (required): an id for the step, unique within the pipeline. Two steps with one id are rejected by the structural gate.
  • run (required): the command this step runs.
  • name (optional): a display name for the step. A plan lists each step by its id, then its name, above the steps' run commands.

Steps run in declared order. Concurrent execution and dependencies between steps aren't supported, so a pipeline-level env, a max_concurrency above 1, or a step's cwd, env, or depends_on is rejected by the structural gate and not silently ignored. Order steps by position, and inline any per-step environment or working directory into the step's run (cd … && FOO=bar cmd).

build

Builds a tool from a fetched source archive and installs it, for upstreams that ship source and no prebuilt binary (zig, ghostty). The source is content-addressed and provenance-pinned: Zap records which exact source produced the installed artifact, so a moved upstream is detected as drift and not silently accepted. It is unrelated to deploy.build, the local command zap deploy runs before packaging (deploy.md).

zap.build({ id: 'ghostty', source: { type: 'remote', url: 'https://release.files.ghostty.org/1.3.1/ghostty-1.3.1.tar.gz', extract: {type: 'tar.gz', strip_components: 1}, verify: { scheme: 'minisign', public_key: 'RWQlAjJC23149WL2sEpT/l0QKy7hMIFhYdQOFy0Z7z7PbneUgvlsnYcV', }, // blake3: '…', // vendored pin (see below); omit to track via zap.lock }, run: 'zig build -p $HOME/.local -Doptimize=ReleaseFast', output: '$HOME/.local/bin/ghostty', });

Fields

  • source (required): where the source comes from and how its integrity is verified. See source.
  • run (required): the build command, run in the extracted source directory, such as zig build -p $HOME/.local. It runs as the connecting user, unprivileged, so an output under a root-owned directory needs the command's own sudo.
  • output (required): a path that exists if and only if the build is installed. It is the detection probe.
  • cwd (optional): the working directory for run, taken as written (no reference, no ~ expansion): an absolute path, or a path relative to the extracted source directory, which is the default.
  • env (optional): extra environment for the whole of run, on a remote target as on a local one. Values resolve references and can hold any byte but U+FDD0, U+FDD1, and NUL, so env is how a build takes a secret. A name can't begin with __zap_.

The build toolchain itself (zig, GTK dev packages, and so on) is not this resource's job. Declare it with sibling package or installer resources and order with depends_on.

source

The source is a remote archive:

  • type (required): 'remote'.
  • url (required): the fetch location (URLs). It may carry a reference, resolved when Zap fetches; how the lock addresses it is in lock.md.
  • extract (optional): how the archive is unpacked. {type: 'none'} is the default; the other form is {type: 'tar.xz' | 'tar.gz', strip_components?: N}. strip_components lives inside the tar form (beside none it is rejected), and strips leading path components: 1 flattens a tarball's single top directory.
  • auth (optional): a bearer credential for a private byte host, resolved at the IO boundary and never hashed.
  • verify (optional): upstream-signature verification. See Verification.
  • blake3 (optional): a vendored source pin. Present, the source pins itself; absent, it tracks through zap.lock, where zap update <id> advances it; a tracked source with no lock entry, or with an entry gone stale (the declaration changed since it was minted), holds the build (undetermined, exit 1) in the read-only commands. See lock.md.

Verification

When source.verify is set, the fetched archive is verified before it is cached or pinned, so a tampered fetch can never become a build. The supported scheme is minisign:

  • scheme (required): 'minisign'.
  • public_key (required): the pinned minisign public key, the 56 base64 characters beginning RW on the key line of a .pub file. Any other shape fails at config eval.
  • signature_url (optional): an explicit signature URL, which may carry a reference. It defaults to the source URL with .minisig appended to its path, before any query or fragment. The source's auth goes with the signature request only when the signature URL has the source's origin (scheme, host, and port); a signature on another origin is fetched without it.

Verification shells out to the minisign binary: the one ZAP_MINISIGN names, an absolute path, else the first in an absolute directory on PATH. Its exit status is the verdict that lets the bytes be cached and pinned, so Zap refuses a minisign inside a node_modules directory (an npm script's PATH leads with node_modules/.bin, which any installed package can fill), and one where the binary, or any directory from where it resolves up to / (and the same for the directory it was found in and for each link on the way), is writable by a group or others, or owned by an account other than root and you. Once those directories are trusted, only root or you could swap the binary between the check and the run. A refusal or a failed verification names the binary's path. Zap adds no crypto dependency of its own and uses the upstream's trust mechanism.

A good signature says the key's holder signed these bytes, not that they are the version the URL names: any archive the same key signed verifies, an older release included. The pin is what fixes the version, and a tracked pin is minted from whatever verified archive the URL served at that fetch, so read the pin change in the lock diff as you would a version bump.

It runs once, at the fetch on a cache miss. A cache hit hashes the bytes against the pin again. Bytes that hash to the pin are the pinned bytes, however they reached the cache: a tracked pin is only ever minted from a verified fetch, and a vendored pin is your own statement of exactly which bytes to trust.

Detection

After a successful build Zap writes a provenance sidecar, <output>.zap-build: a blake3 over the source pin and the build's extract, run, cwd, and env as declared. Detection compares it with the desired provenance, so a moved upstream or a changed build rebuilds (detection.md).

See also

  • concepts.md: the model and the verdicts.
  • configs.md: writing a config; ordering, triggers, the gates.
  • detection.md: how each resource type's current state is read.
  • lock.md: pins, zap.lock, and the sentinel and sidecar files.
  • env.md: references, the env file, secrets.
  • deploy.md: the zap deploy release mechanism.
  • recipes.md: the shipped config fragments.

Recipes #

Reusable config fragments shipped with Zap: the standard pieces of a hardened Linux server, so a config imports them instead of pasting an nginx block or a systemd unit that drifts from the next one. Each is a pure function or a constant (no I/O; it runs inside the config sandbox) that gives a string to put in a file, or a payload to spread into a resource.

import {hardened_systemd_unit} from '@fuzdev/zap/recipe_debian_systemd.ts'; import {debian_web_firewall} from '@fuzdev/zap/recipe_debian_firewall.ts'; zap.file({id: 'unit', path: '/etc/systemd/system/app.service', content: {type: 'inline', content: hardened_systemd_unit({…})}}); zap.firewall({id: 'fw', ...debian_web_firewall()});

Import each by its full module path. Resolving the imports: install.md. The generated API reference on the site documents every option; this page is the map.

Host families

Recipes that differ by distro come in symmetric debian_* / alpine_* pairs over a shared neutral core, so a config imports one family per machine: a Debian config can't pick up an OpenRC service by accident, because the names differ. The neutral recipes, identical on every distro, keep bare names. A few carry the family in the artifact's own name (hardened_systemd_unit, hardened_openrc_service, postgres_apt_repo) rather than a prefix.

Neutral

ModuleExportsWhat it gives you
recipe_nginx.tsnginx_site_corethe portable single-page-app server block: static root, /api proxy with a rate limit, security headers, CSP, and a redirect to it for alias names such as www.; returned as whole template content, the domain and port you pass held to hostname and port. An alias name is literal text, where {{domain}} may stand for the domain (www.{{domain}}), or carries a reference (zap.env('MIRROR_DOMAIN')), which becomes a variable of its own, redirect_domain_<i> for the entry at index i, held to hostname
recipe_nginx.tsnginx_catch_all_core, nginx_catch_all_server, nginx_remove_default_corethe catch-all core: the stock default site removed, then a default_server on every socket the site listens on that closes a request for any other host name (444) and refuses its TLS handshake, carrying the site's SSL parameters
recipe_nginx.tsssl_params, rate_limit_zonethe TLS and rate-limit snippets
recipe_nginx.tsSSL_PARAMS_PATHwhere the TLS snippet goes, the same on both distros; the site wrappers include it from there
recipe_nginx.tsRATE_LIMIT_ZONEthe rate-limit zone name an extra_locations block's own limit_req references
recipe_nginx.tssite_security_header_directivesthe security-header lines, the site's CSP included, an extra_locations block with its own add_header repeats
recipe_nginx.tsSECURITY_HEADER_DIRECTIVESthose lines without the CSP, for a block that sets a policy of its own
recipe_nginx.tsNGINX_VALIDATEsudo nginx -t, for a file's validate
recipe_nginx.tsnginx_config_verifya verify payload that re-checks the config every plan
recipe_csp.tsspa_cspa Content-Security-Policy value for a single-page app, with hooks for a websocket origin and extra connect-src, img-src, worker-src entries; pass it to the site recipe's csp
recipe_ssh.tsssh_hardeningan sshd_config.d drop-in (key-only, no root login, AllowUsers the given user, one name with no whitespace, tightened auth tries and grace time); name it to sort first, such as 00-hardened.conf, since sshd keeps the first value it reads
recipe_ssh.tsSSHD_VALIDATE, ssh_hardening_verifysudo sshd -t, for the drop-in's validate, and a verify payload that asserts the hardening in the config sshd applies to the given user's login (Match blocks included) every plan, AllowUsers (the given user and no other, since sshd adds up AllowUsers across files) and the tunables included; give it the options ssh_hardening got
recipe_ssh.tsssh_service_corethe sshd service core: enabled, running, reloaded when its reload_on ids change
recipe_certbot.tscertbot_standalone_issue_core, certbot_webroot_switchthe first-issue (standalone) core, which stops nginx with the stop command it is given and then requests the certificate, and the renewal (webroot) script payload, for the site's domain and any alias names. The webroot switch re-issues the certificate and then runs the deploy hook it is given, since certbot before 5.3 runs deploy hooks only on renew. Both run the DNS preflight first
recipe_certbot.tscertbot_deploy_hook_core, CERTBOT_DEPLOY_HOOKS_DIR, RELOAD_NGINX_HOOK_NAMEthe deploy-hook file core: an executable in /etc/letsencrypt/renewal-hooks/deploy that certbot runs after each renewal, whatever scheduled it, and the reload-nginx hooks' default file name
recipe_certbot.tsletsencrypt_live_pathwhere certbot keeps a certificate's chain and key, /etc/letsencrypt/live/<domain>/; the nginx site and the certbot scripts both read it
run_parts.tsvalidate_run_parts_name, RunPartsFilethe one check of a hook or job file name: letters, digits, _ and - only, the names run-parts runs; and the spreadable file fields the hook and job builders return
recipe_fail2ban.tsfail2ban_jail/etc/fail2ban/jail.local with an sshd jail. It defaults to Debian's journal and a ufw host's iptables-multiport ban action; on Alpine set sshd_logpath: '/var/log/messages' and banaction: 'nftables'.
recipe_deno.tsdeno_installa pinned Deno install script, system-wide or into a user's home. Its target triple defaults to glibc; set it on Alpine.
recipe_sysctl.tsLINK_PROTECTION_SYSCTLS, LINK_PROTECTION_SYSCTL_PATH, sysctl_conf, sysctl_applythe kernel's hard-link, symlink, FIFO and regular-file protections (systemd sets them on Debian; Alpine sets only the first two), as a sysctl.d file and a script that loads it when a live value differs
recipe_firewall.tsWEB_FIREWALL_ALLOW, web_firewall_allow_rulesthe SSH + HTTP + HTTPS allow set as port rules, so both backends read the same rules
recipe_env.tsfuz_app_env_base, fuz_app_trusted_proxiesthe .env.production content and trusted-proxies stanza for a server built on fuz_app: the header as whole template content (port held to port, cookie_keys to env_value), your own lines appended through extra with their variables and kinds; its DATABASE_URL reaches PostgreSQL over the Unix socket as the service's own account (peer)
recipe_bootstrap.tsfuz_app_bootstrap_tokenthe one-shot first-admin bootstrap token script for a fuz_app server

Debian and Ubuntu (apt, systemd, ufw)

ModuleExportsWhat it gives you
recipe_debian_nginx.tsdebian_nginx_spa_site, debian_nginx_catch_allthe SPA site's template content, for a file in sites-available/ and link into sites-enabled/, and the catch-all: a script removing sites-enabled/default and a file at conf.d/00-catch-all.conf, ordered after it. A site meant to answer any host name declares only the script
recipe_debian_systemd.tshardened_systemd_unita unit with NoNewPrivileges, ProtectSystem=strict, ProtectHome, private tmp and devices, restricted address families, an explicit ReadWritePaths list, no capabilities, the @system-service syscall filter, and UMask=0077 (each of the last three loosened by its own option); refuses root as its user or group and an ExecStart prefix that escapes User= or the sandbox
recipe_debian_certbot.tsdebian_certbot_standalone_issue, debian_certbot_reload_nginx_hookthe first-issue bootstrap script, stopping nginx with systemctl, and the deploy hook that reloads nginx after a renewal (certbot.timer renews, but reloads nothing)
recipe_debian_ssh.tsdebian_ssh_servicethe sshd service on systemd's ssh unit, which Ubuntu's ssh.socket also starts; not for a Debian host with a hand-enabled Accept=yes socket
recipe_debian_postgres.tspostgres_apt_repothe PGDG apt_repo payload
recipe_debian_firewall.tsdebian_web_firewallthe ufw firewall payload (ufw persists itself)

Alpine (apk, OpenRC, nftables)

ModuleExportsWhat it gives you
recipe_alpine_nginx.tsalpine_nginx_spa_site, alpine_nginx_catch_allthe SPA site's template content, for a file at http.d/<domain>.conf, and the catch-all: a script removing http.d/default.conf and a file at http.d/00-catch-all.conf, ordered after it
recipe_alpine_openrc.tshardened_openrc_servicea supervise-daemon init script with capability bounding, umask 0077 (loosened by its option), ulimit, respawn, and an optional bwrap isolation tier with an explicit read-write path list; refuses root as its user or group
recipe_alpine_certbot.tsalpine_certbot_standalone_issue, alpine_certbot_renew_cron, alpine_certbot_reload_nginx_hook, alpine_periodic_daily_file, ALPINE_PERIODIC_DAILYthe first-issue bootstrap script, stopping nginx with rc-service, the daily renewal script BusyBox crond runs, the deploy hook that reloads nginx after a renewal, and the /etc/periodic/daily placement helper
recipe_alpine_ssh.tsalpine_ssh_servicethe sshd service on OpenRC's sshd
recipe_alpine_postgres.tsalpine_postgres_setupthe OpenRC cluster-init script (apk doesn't run initdb for you), with initdb's trust rules turned to peer and scram-sha-256
recipe_alpine_firewall.tsalpine_web_firewallthe nftables firewall and the nftables persistence service the structural gate requires

Writing your own

A recipe is an ordinary pure function, so a config can define its own. One that needs a reference of its own (a default domain, say) makes it with env_ref('NAME') from @fuzdev/zap/env_ref.ts, which returns what zap.env('NAME') does. A value a caller passes in may be a reference too, so a recipe splices values into strings and never inspects them beyond whether one carries a reference: one that validates or slices an option fails on a reference. A value that ends up in a file's inline content can't be a reference; a recipe that takes a per-environment value emits a {{placeholder}} for it and returns the whole template content, the value in vars and its kind in kinds.

Design rules

The shipped recipes follow these, and they keep your own composable too.

  • One value, one home. Anything two recipes must agree on (a rate-limit zone name, a token path, the ACME webroot) is one constant or flows through one recipe's option. The nginx rate-limit zone is the precedent: rate_limit_zone defines it and nginx_site_core references it through one exported constant, RATE_LIMIT_ZONE, so the two can't drift.
  • A neutral recipe never defaults a distro-divergent value. A default that differs by family either forces the debian_* / alpine_* split or becomes a required option. The neutral recipes that still carry a Debian default are flagged in the catalog: fail2ban_jail's sshd_logpath and banaction, and deno_install's target.
  • Big divergence, two functions. A one-verb or one-path difference shares a *_core with thin wrappers (the nginx catch-all, the sshd service). A whole-artifact difference (a systemd unit versus an OpenRC script) is two functions sharing only an options shape.
  • Pure. No fetch, no file reads beyond imports, no clock. A recipe's output depends on its arguments alone, so the plan does too.
  • Every value a shell reads is one single-quoted word. A recipe that splices a value into a command or a script quotes it with single_quote (@fuzdev/zap/shell.ts), whether the value is a reference or a literal, so no value can change what the command runs. An option that is shell words by design (psql_args, command_args, a stop or reload command) says so in its doc. A value a second shell reads again, as OpenRC's start line does, is held to a plain-word pattern and refused otherwise. An env file has two readers, systemd's EnvironmentFile= and, on Alpine, the init script's reader, so fuz_app_env_base and fuz_app_trusted_proxies single-quote their values, and refuse one holding a ' or a line break, which systemd reads differently.
  • A recipe that owns a template owns its kinds. A placeholder's value is substituted as text at apply time, so a ' in '{{cookie_keys}}' would end the quoting (systemd would read another value, the OpenRC service would refuse to start), and a ; or a } in a value placed in an nginx directive would end the directive. The site and env recipes return the whole template content, each variable with the kind of the place it lands (resources.md), so a caller passes values; used whole, the content carries its kinds, and overriding kinds or rebuilding the content drops them. fuz_app_env_base's extra adds the caller's own lines, variables, and kinds, and can't name the base's; the site recipes take no variables but domain and port, and add a redirect_domain_<i> of their own for each redirect_domains reference.
  • A user that sudo -u runs a command as is written out. sudo -u reads a name starting with # as a uid (#0 is root), so a recipe that runs a command as a declared user refuses an empty one, one starting with #, and a reference, whose value it can't see (sudo_as_user in @fuzdev/zap/shell.ts).
  • A binary a service user owns runs as that user. deno_install into a user's home runs its check and its closing --version as that user, never as the connecting user: the account could replace the binary, and the connecting user has sudo.

The certbot DNS preflight

Let's Encrypt limits failed validations, and a fresh host whose DNS isn't pointed at it yet fails every one. So each certbot script that leads to a CA request — the --standalone issuance and the webroot switch (a re-issue, and a new name on the cert is a request for it) — first checks, on the target, that every name the certificate covers (domain and each of extra_domains) resolves only to expected_addresses. The checks are unchanged.

  • Each name is resolved with getent ahosts and getent hosts, which both families have (glibc's on Debian, musl-utils on Alpine). A name ahosts can't resolve refuses, and so do lookups that print no address and any address either prints outside the expected set: the CA validates whichever A or AAAA it picks, so an undeclared AAAA refuses too.
  • Both lookups, because ahosts asks with AI_ADDRCONFIG: on a host with IPv6 disabled, glibc and musl both leave the AAAA records out of it, and getent hosts still prints them. glibc prints every one, musl only the first, so on a musl host with IPv6 disabled a name with several AAAA, the first of them declared, passes with the others unseen.
  • expected_addresses defaults to a reference to DEPLOY_HOST, the target's host. Each value is an IP literal or a reference resolving to one; the target checks it when the script runs and refuses anything else, asking for expected_addresses. A DEPLOY_HOST holding a host name rather than an IP literal refuses until expected_addresses is declared; declare the set too when the names carry an AAAA. An IPv6 address is compared as lowercase text: write it as getent prints it, compressed.
  • The issuance stops nginx to free port 80 inside the same run, after the preflight passes and before certbot runs, so the stop runs only once every name the request covers has passed, and a refusal leaves nginx running. If certbot then fails, nginx stays stopped; the resources after the certificate, nginx's own service among them, are skipped until a later apply's request succeeds.
  • A refusal exits nonzero before nginx is stopped or certbot runs, with each reason on stderr:
certbot DNS preflight: example.org resolves to 2001:db8::7, outside the expected addresses (203.0.113.7); the CA validates whichever address it picks, so point every A/AAAA record of the name at this host, or add the address to expected_addresses if it belongs to this host certbot DNS preflight: www.example.org does not resolve on the target (getent ahosts exit 2); point its A/AAAA records at the expected addresses (203.0.113.7) before certbot runs certbot DNS preflight: refused; nothing was run

The preflight reads the target's resolver, not Let's Encrypt's, so it can be wrong in both directions. An /etc/hosts line, a split-horizon answer, or a cached one can pass while public DNS doesn't point at the host yet, and the request then fails validation as it would have without the preflight; the same sources can refuse while public DNS is right. getent reads /etc/hosts first, so a name the host maps to a loopback address there refuses, among them the 127.0.1.1 <name> line a Debian install writes for the host's own name when that name is the domain. Remove the line, or give the host a name that isn't the domain; a tool that manages /etc/hosts may write the line back.

A script has no lock entry and is done once its check passes, so changing its run re-runs nothing: a host whose certificate is issued and pinned to webroot never runs the preflight, and one whose check fails, for a name newly added to the cert, runs it before the re-issue.

Env files under OpenRC

OpenRC has no EnvironmentFile=, and the init script runs as root, so hardened_openrc_service never sources env_file. The script carries a reader that takes each line as data. start_pre runs it as the service user (su -s /bin/sh), under a 10-second timeout, to check every line and assign nothing. The check writes to a file in a private directory of root's under /run (mktemp -d), never to a pipe, so a process of the service user's that outlives the timeout can't hold the start open, and the service user can't fill the directory beforehand to stop the start, as it could /tmp. Root reads only the first 4 KiB, which bounds what it reads, not what a lingering writer can grow the file to; and the daemon's own process, already dropped to the service user, runs it again to export the values before it execs the command. Nothing in the file is run or expanded, root never parses it, and nothing in it reaches root's environment. The file must be readable by the service user: one it can't read (a root-owned 600 file), a line outside the format, or a file that isn't read in time (a FIFO) refuses the start, naming the user or the line. The check needs su and timeout on the target, which BusyBox gives Alpine.

The format is a subset of what systemd reads, so a file in it gives the daemon the same environment under both:

  • a blank line, or one whose first non-blank character is # or ;, is skipped;
  • every other line is NAME=VALUE, the name [A-Za-z_][A-Za-z0-9_]* with nothing before it, and not beginning _zap_;
  • a value that starts with ' ends the line with its closing ' and holds no other; everything between is literal;
  • any other value is literal as written, $, # and inner blanks included, and may not start or end with a blank, start with ", or hold a \;
  • NAME= sets the empty string, and a later line for a name wins.

Any other line refuses the start, naming its line number, where systemd would skip it (no =, an invalid name), strip blanks around the name or value, read double quotes, backslash escapes or a continued line, or let a quoted value span lines. A carriage return anywhere refuses its line.

Recipes for fuz_app servers

fuz_app_env_base, fuz_app_trusted_proxies, and fuz_app_bootstrap_token are specific to servers built on fuz_app: its environment layout and its first-admin bootstrap. Everything else on this page is generic: an nginx site serving a static build with an /api proxy, a hardened service, a firewall that allows 22, 80, and 443.

Targets, SSH, and privilege #

Where a config acts, how Zap gets there, and what the target needs. What your own machine needs is in install.md.

The target

A config declares exactly one target with zap.target({…}): your own machine, or a host over SSH.

// your own machine zap.target({local: true, package_manager: 'apt', service_manager: 'systemd'}); // a host over SSH zap.target({ host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER'), home: '/home/deploy', package_manager: 'apt', service_manager: 'systemd', });
FieldLocalRemoteMeaning
localtrueomit or falsewhich kind of target
hostrejectedrequiredthe SSH host; usually a reference. A name or an address alone: it can't begin with - or hold @, and an IPv6 address is written without brackets. A remote target without one doesn't validate
userrejectedoptionalthe SSH user; ssh's default when the target declares none. It can't begin with - or hold :, and a declared one that resolves to nothing is an error in every command that connects (plan, detect, apply, deploy)
bootstrap_userrejectedoptionalthe alternate user --bootstrap connects as (below)
portrejectedoptionalSSH port, 1 to 65535. Unset (or 22), ssh's own choice applies, your ~/.ssh/config included
key_pathrejectedoptionalan SSH private key, passed to ssh -i
homerejectedoptionalthe remote home that ~ and $HOME in paths resolve against
package_managerapt, apksameselects the package backend. pacman and brew parse but have no backend.
service_managersystemd, openrcsamerequired whenever the config has service intent (a service, a file with triggers, or deploy.service); launchd parses but has no backend

A local target with any remote field is rejected at parse. A remote path that uses ~ or $HOME with no home declared is undetermined: Zap never guesses a remote home.

host, user, and bootstrap_user are connection context, not desired state: they are left out of every resource's hash, so changing the connecting user never reads as a config change. The plan does name them: plan_hash covers the host and the connecting user the target resolves to, and on a local target the user Zap runs as and its HOME. A target that declares no user leaves the choice to ssh, and the plan prints user: (none — ssh chooses) instead of a name.

How Zap reaches a remote host

Your authority to act on a host is SSH. Zap shells out to the ssh and scp on your machine with your keys and your ~/.ssh/config, so anything that works for ssh user@host works for Zap: agents, jump hosts, per-host keys. The options Zap sets:

OptionWhy
BatchMode=yesnever prompt for a password; key authentication only
StrictHostKeyChecking=accept-newa new host is trusted on first use; a changed key is refused, with a hint in the error
ConnectTimeout=10an unreachable host fails fast
ServerAliveInterval=30, ServerAliveCountMax=3a connection that goes silent is dropped, not waited on
ForwardAgent=no, ForwardX11=no, ClearAllForwardings=yesno session, root's included, gets your agent, your display, or a port forward, whatever your config says
RequestTTY=no, PermitLocalCommand=nono terminal on the host, and no LocalCommand run on your machine
Tunnel=nono tunnel device forwarded, whatever your config says
ControlMaster=auto, ControlPersist=300, ControlPath="<zap home>/ssh/…"one authenticated connection per run, shared by every command, and kept warm for five minutes, so a run in that window reuses it rather than authenticating again. Left off when the socket's path would be too long for one (a Zap home over 38 bytes) or the home holds a double quote, a backslash, or a control character; Zap says so once

ssh keeps the first value it reads for an option, and the command line comes before every config file, so these hold over your ~/.ssh/config. A changed key is refused only when the first one was recorded, so before it connects to a host Zap reads your effective config once (ssh -G, with the same options) and refuses, naming the setting, when every UserKnownHostsFile is /dev/null.

There is no agent on the host, no daemon, no server, and no shared secret. A pass authenticates once: detection runs as one or two batched shell scripts, and the login batch waits for the root batch's connection to be up rather than opening a second one.

The bootstrap user

A config that hardens SSH often changes the connecting user partway through bring-up: the host's stock account creates a dedicated service user, then SSH hardening locks the stock account out. Declare both:

zap.target({ host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER'), // the hardened service user, steady state bootstrap_user: zap.env('BOOTSTRAP_USER'), // the stock account, first run only });

Pass --bootstrap on the first run to connect as bootstrap_user, and omit it afterward. It never engages on its own, so a connectivity failure can't silently switch identity. The first bring-up is zap apply --bootstrap, then zap apply --bootstrap --wetrun: it provisions and hardens as the stock account, which the hardening then locks out, and every later command connects as the service user. A --bootstrap run is its own plan, so preview it with zap apply --bootstrap; a preview made as the service user doesn't bind it.

Keep both variables in one env file, set from the start and kept set: a host or connecting user that resolves to nothing stops every command before it connects, and an unset reference in the other user makes a dry run unverifiable and stops a wetrun.

What the target needs

Debian / UbuntuAlpine
shell and toolssh, GNU coreutils (timeout), util-linux (setsid)BusyBox, validated on 1.37 (Alpine 3.24): its timeout must take the duration without -t, and its mv must take -T
escalationsudo, working without a password for the connecting user (or you connect as root)sudo (not doas)
for file content checks and every file writesha512sum and a mounted /procthe same
for a file or directory writemktemp, stat, sync, mv -T, ls, id, and a mounted /procthe same, as BusyBox applets
for a remote archive or a build sourcesshd's SFTP subsystem (what scp uses from OpenSSH 9.0; an older scp needs scp on the target), mktemp -dthe same
for an extracted archive or a deploytar with --no-same-owner, --no-same-permissions, and --strip-components; id; rmdir and ls for an extracted archivethe same
for a deployflock (util-linux), and sort to prune old releasesthe BusyBox flock and sort applets
for a useruseradd and usermoduseradd and usermod from the shadow package (apk add shadow): BusyBox's adduser isn't used
for user.sudovisudovisudo (the sudo package ships it)
for installercurlcurl
for firewallufw (the config may install it)nft plus an nftables service
for databasePostgreSQL with a postgres system accountsame

Zap needs sudo on the target even when you connect as root: privileged commands carry an explicit sudo so one plan runs under either user. A fresh Debian cloud image usually has it; a minimal Alpine needs apk add sudo by hand or through a bootstrap user that already has it.

Two-tier privilege escalation

Which commands run as root is decided by what they are, not by who you are.

  1. Intrinsically privileged commands carry sudo. apt-get install, systemctl, rc-service, ufw, nft, useradd, and the like are baked into the plan with an explicit sudo prefix. The plan reads the same for a root login and a sudoer.
  2. Filesystem operations run under sudo over SSH, and escalate per operation locally. Over SSH the connecting user isn't assumed to own what Zap manages, so writing a file, making a directory, placing a fetched file, or creating a symlink goes through sudo directly. On a local target each is tried natively first and falls back to a sudo shell op only when denied, so a config that touches only your own files never invokes sudo. A file's mode and owner are part of its write, and a directory's are set the same way: natively, else by one elevated command that refuses a symlink at the path. A file keeps the owner and group it leaves unset, so locally a file another account owns, or a new one in a directory another account owns, is written under sudo; so are directories made under another account's, which take its owner. Anywhere below a directory with its sticky bit set or that every account may write, such as /tmp, nothing is kept or taken and your own write stands. Local reads and existence checks follow the same rule: a root-owned file is read under sudo rather than misread as absent.

A user's authorized_keys is written as that user (sudo -u <user>), a copy_from source is read as the source user, and a database's SQL runs as postgres (sudo -u postgres), so the connecting user's sudo rule has to allow running as them. root and an unrestricted sudoer do. What ran as root is in the host's sudo I/O log (security.md).

The exception is a build: its source is extracted into a private temporary directory and the build command runs unprivileged, as the connecting user. A root-owned scratch dir would be unrepairable, and a build shouldn't need root.

Local sudo

On a local target, before a root batch runs, Zap checks that sudo will work without a prompt (sudo -n). If it won't, Zap runs sudo -v on your terminal, with no deadline, so you can enter a password before anything is timed. The batch itself then runs under sudo -n. If sudo won't authenticate, every resource that needed root is held with the reason sudo authentication failed.

An operator command (a script's check, a verify assertion) runs without a terminal, locally as over SSH. A sudo inside it that needs a password fails at once and the check reads as failed. Give such a command a NOPASSWD rule, or keep sudo out of checks.

Detection privilege

Every detection read runs under a declared privilege: login (the connecting user, unprivileged), root (root on every connection), or elevated (root over SSH; locally, you first and sudo for a read that failed). Which reads run in which lane, and how batches are framed, killed, and timed out: detection.md.

Environment and secrets #

How values that vary per machine, or must stay secret, reach a config without being written into it. The sandbox that makes this necessary is described in configs.md.

References

A config never reads the environment. It writes a reference instead, with zap.env('NAME'):

zap.target({host: zap.env('DEPLOY_HOST'), user: zap.env('DEPLOY_USER'), package_manager: 'apt'}); zap.file({ id: 'env', path: '/srv/app/shared/.env', content: {type: 'template', template: 'DATABASE_URL={{url}}\n', vars: {url: zap.env('DATABASE_URL')}}, });

The reference stays inert in the declaration and in the plan. Zap resolves it from the operator environment when it acts, in these fields only:

Resolves a referenceTakes none: write the value out
targethost, user, bootstrap_userport, key_path, home
pathsresource paths; a package's check and an installer's check when they are pathsa swap's path; an apt_repo's key_path and list_file; a script's or build's cwd; deploy.path
contenta template file's vars valuesinline content and a template's body (written as declared)
commandsscript, installer, build, and pipeline commands; an installer's shell; a file's validate; a script's and a build's env valuesa verify assertion and a package's check when it is a command (run as written)
namesa database's identifiers and auth.hostsa package name, a service name, deploy.service, a user's shell, ssh_keys.keys, and sudo commands, a named firewall profile
URLsa remote URL and its auth token, a build source's signature URL, an installer's URL, an apt_repo's key_urlan apt_repo's source
othera swap's size, deploy.health.domaindeploy.health.path, and every field not listed

A reference where none is taken fails when Zap reads the config, naming the resource, the field, and what to use instead:

error: config did not match the zap schema (/home/ops/infra/zap.ts): 2 references in fields that take none: - resource `motd`: `content.content` takes no reference: the content is written as declared; put the value in a template's `vars`, which resolve - resource `work`: `cwd` takes no reference: zap enters a working directory as declared and never resolves it; write the path out

A reference in a command is replaced as text: put it inside single quotes, or hand the command an arbitrary value through env, which is quoted after it resolves. The values Zap refuses in a command, and why: security.md. A template's vars value can't hold a line break, which would add a line to the file (resources.md). An env-file value can't hold a NUL. A value holding U+FDD0 or U+FDD1 is refused wherever it would be substituted, an env value included: a value is substituted once and can't carry a reference of its own.

zap.env('NAME') returns a string, so a reference composes like one:

zap.directory({id: 'data', path: `/srv/${zap.env('APP')}/data`});

That path has a reference in it, and a recipe can splice a value it was handed without knowing whether it is one. A recipe makes its own with env_ref('NAME'), imported from @fuzdev/zap/env_ref.ts. The name must be an environment variable name (letters, digits, and _, not starting with a digit).

While the config is evaluated that string is opaque. It holds a marker, not the value, so interpolating and concatenating it are the only things to do with it:

  • slicing it, splitting it, or changing its case damages the marker, and the config fails to evaluate, naming the field;
  • a regular-expression replace that strips non-word characters, or encodeURIComponent, silently turns it into ordinary text;
  • code that inspects a value (its length, its first character) sees the marker.

A reference is the string zap.env or env_ref returns. Text that merely looks like one is text: $$, $$NAME$$, ${NAME}, and {env:NAME} in a command or a file are what they say. The two characters the marker is built from (U+FDD0 and U+FDD1) can't appear in a config's own strings; content that holds them comes from a source or remote file.

Previews and messages show a reference as {env:NAME}:

[+] conf (file) path: {env:APP_DIR}/app.conf content: template (47 bytes, sha512 527b3af39db7aeb8…), vars {"domain":{"$env":"DEPLOY_DOMAIN"},"key":{"$env":"API_KEY"},"port":8080}

What each reference a plan uses resolves to is printed once, in a references: block under the target, and is part of the plan and its hash: a plain reference's value, or for a secret only that it is one. So a value you wouldn't want in a preview or a CI log belongs under a name Zap reads as a secret, or in zap.mask.

references: API_KEY is a secret: its value is not shown, and no part of it is in the plan or its hash APP_DIR = /srv/app DEPLOY_DOMAIN = app.example.com

Text in a command can spell {env:NAME} too. Where it matters which it is, the target: line and a template's vars print the exact form, {"$env":"NAME"}, and plan_hash covers it.

A command, a path, or an SSH destination that still holds an unresolved reference is never run, touched, or connected to: Zap refuses first.

Target-categorized resolution

Two kinds of placeholder resolve against two different machines:

PlaceholderResolves againstIn the plan
zap.env('NAME')the operator's environmentas a reference; the value arrives when Zap acts
~, $HOMEthe target's home: target.home remotely, your own locallyas written

So a resource's part of the plan doesn't depend on the environment or the target's home, and a remote path that needs a home with none declared is undetermined rather than guessed. The home itself is part of the plan beside it: a remote one as the declared target's home, your own as the local plan's home: line. A resource's resolved values reach the hashes of the last-run report and, for a tracked source, the lock.

The target's host, user, and bootstrap_user resolve from references too; they are connection context (targets.md).

The env file

Every config command accepts --env-file <path>, a file of NAME=VALUE lines loaded into the operator environment before anything resolves. A name set in both the file and your shell takes the file's value:

zap apply --env-file zap.env zap deploy --env-file zap.env --wetrun

One explicit file, no auto-discovery, no .env plus .env.<mode> merging. Keep env files out of version control and commit a .example with the names. Name it for what it is, say zap.env or zap.production.env; don't reuse the name of an env file your app reads on the host, which deploy.shared may be linking into releases.

The format is a strict subset of dotenv: Zap reads only lines the common loaders agree on, and a line it doesn't understand is an error instead of a guess. (A loader that interpolates still reads a $ its own way.)

LineReads as
blank, or beginning #nothing
NAME=valuethe value to the end of the line, surrounding whitespace trimmed
NAME='value'the value as written
NAME="value"the value, with \\, \", \n, and \r decoded
export NAME=valuethe same as without export

A name is letters, digits, and _, not starting with a digit. There is no interpolation ($OTHER is text) and no value spanning lines. These don't load: a line with no =, a name that isn't one (export counts only before a space), a name assigned twice, a quote left open or followed by anything but a comment, an unquoted value that begins with # or holds one after whitespace (quote the value, or move the comment to its own line), an unquoted value ending in a backslash or holding a control character other than a tab, or a Unicode line or paragraph separator (U+2028, U+2029), and a NUL in an assignment. Whitespace around the = is trimmed, and a byte-order mark at the start is skipped. The error names the file, the line, and the name when the line has one, and never any part of a value or of the line.

Zap warns, once per command, about an env file its group or everyone can write (whoever can rewrite it chooses the host a deploy goes to), and about one they can read when it holds a secret by its name or the form of its value (the file is read before the config, so a variable only mask() lists doesn't count), saying which. chmod 600 answers both.

Unset references

A reference with no value, or an empty one, is handled by what the command does:

  • A wetrun (apply or deploy) refuses to start. Every referenced variable must be set and non-empty, or the run fails before any side effect. An empty DATABASE_URL would otherwise be written into a .env file as nothing.
  • A comparing dry run (plan, apply, detect) is unverifiable. Detection resolves references into the desired side of its comparison, so an unset one makes the answer unsound in both directions: it can hide drift (the target already holds the empty value) and invent it (the target holds the real one). The command leads with the banner and exits 1:UNVERIFIABLE — 2 referenced variable(s) are unset or empty, so this comparison cannot be trusted: DATABASE_URL, SMTP_PASSWORD an unset reference resolves to an empty string, which can both hide real drift and invent drift that isn't there. provide them (--env-file or the environment) and re-run.

    The check covers what the comparison resolves: the resources, and the target's host, user, and bootstrap_user. The user the run connects as is held to more: if it resolves to nothing the command stops before it connects (below), since ssh would pick an account of its own. It ignores the deploy block and a fetch credential (the auth token of a remote file or a build source). A database's auth.hosts is part of the comparison and is covered.

  • A deploy dry run warns. It compares nothing (the preflight is wetrun-only), so its rendered plan is sound.
  • A remote host, or a declared connecting user, that resolves to nothing is an error before any connection. Zap won't SSH to nowhere, or as whichever account ssh picks: plan, detect, apply, and deploy exit 2, naming the variable that is unset or empty.

Unverifiable

The banner above is the unverifiable verdict: the desired side of a comparison couldn't be built. It is the twin of undetermined, where the observed side couldn't be read. Both outrank "converged" and "drifted" in the exit code, because neither claim was established.

Secrets

One rule decides whether a reference is a secret, and everything that treats secrets differently asks it. In order:

  1. Where it is used. A reference in a remote file's or a build source's auth is a secret, whatever it is called.
  2. What you said. zap.unmask('NAME') makes a reference plain, and zap.mask('NAME') makes it a secret. Unmasking a reference used as an auth credential is an error.
  3. Its name, by the rule in Secret names below.
  4. The form of its value. A value is a secret, whatever its name, when it holds a URL with a password (postgres://app:…@db/app, so a DATABASE_URL needs no mask), an http or https URL with a user, git+https included (https://<token>@git.example.com/…: HTTP reads a user only as a credential, where ssh://git@… and postgres://app@… name an account), a PEM private key, or an HTTP authorization value (Basic with its encoded user:password, or Bearer with a token of 16 characters or more, alone or as an Authorization: or Proxy-Authorization: header line). A password holding an unencoded ? or # still counts when no @ comes before that character (postgres://app:p#ss@db/app, as libpq reads it: up to the first @ after it), unless what precedes it is all digits, which reads as a port. A password in a query string or in key=value form, and a secret in a URL's path (a webhook's), aren't recognized: name the variable for what it is or zap.mask it.

What is masked is a value Zap resolves: a name no field references registers nothing, so zap.mask doesn't scrub a value a command reads from the target's own environment.

Secret names

Case-insensitive. A name is a secret's when it:

Words
holds anywhereSECRET, PASSWORD, TOKEN, KEY, CREDENTIAL
has a last word ending inPASS, PASSWD, PASSPHRASE, PASSCODE, PWD, BINDPW, ROOTPW (ordinary words like BYPASS and COMPASS, and the shell's own PWD and OLDPWD, aside)
has a last word that isPW, PAT, DSN, AUTH, PRIVATE, SALT, COOKIE, SESSION, JWT, CRED, PSK, WEBHOOK; or URL or URI after WEBHOOK or WEBHOOKS

The last word is the part after the final _, passing over a trailing number written apart or glued on (SMTP_PASS_2, DB_PASS2; AUTH0 is a name, not AUTH), a version (DB_PASS_V2), and a qualifier naming an environment or a rotation (PROD, PRODUCTION, STAGING, STAGE, DEV, DEVELOPMENT, TEST, TESTING, QA, UAT, OLD, PREV, PREVIOUS, NEW, NEXT, CURRENT, PRIMARY, SECONDARY), so DB_PASS_PROD is a secret. A plural reads as its word (DB_CREDS, HASH_SALTS), except for AUTH, PRIVATE, SESSION, COOKIE, WEBHOOK, and a word ending in PASS, whose plurals count or switch something (MAX_SESSIONS, MAX_PASSES).

So DB_PASS, GITHUB_PAT, SENTRY_DSN, and SLACK_WEBHOOK_URL are secrets, and AUTH_HOST, PRIVATE_IP, and SESSION_TIMEOUT aren't. A secret-named variable is plain while its value is a switch (true, false, yes, no, on, off, or a single digit, in any case, whitespace around it ignored), so ENABLE_AUTH=true masks nothing.

Masked hosts and review

A target.host, a connecting user, or a deploy.health.domain behind a secret reference makes a plan no review can bind, since its hash would name no one machine or be computed from the secret. zap apply --wetrun and zap deploy --wetrun refuse it before anything runs, a dry run shows the plan under an UNBINDABLE note with no plan_hash and records no preview, and a masked host keeps no last-run report. The message names the cause:

The reference is a secret because ofWay out
its name or the form of its valuezap.unmask('NAME') if it isn't one, or write the value literally
the config's zap.mask('NAME')drop the mask
its use as a fetch credential tooreference another variable in the host, user, or domain, or write the value literally
a registered secret carried in its valuetake the value from a literal, or from a variable that carries none

What masking covers

A secret's resolved value is masked out of everything Zap prints or writes: the text report, --json events, warnings, held reasons, drift observations, deploy step output, and errors. Masking is by value. Besides the value itself Zap looks for it trimmed, URL-encoded, JSON-escaped, and single-quoted for a shell as Zap's own commands carry it (each ' written '\''); for each line of 24 characters or more of a value that spans several (a PEM key, with its \n decoded when it was written on one line; the -----BEGIN…----- lines aside); and for the password or token inside a URL or an authorization value, when that is 8 characters or more. A password that also appears elsewhere in its own value (postgres://app:postgres@db/app) is an ordinary word and is masked only as part of the whole. A secret a tool prints in any other form (the address ssh resolved a secret host name to, a URL it rewrote around a short password) is not found. The executor itself never sees a masked string; masking happens where output is consumed. A plan's steps hold only the inert reference; its references: block holds the value of a plain one, with any secret inside that value masked, and for a secret only the fact. Commands reach their shell on stdin, so a resolved value is on no ssh or shell argv (security.md). There is no --show-secrets.

The same rule decides how zap.lock addresses a tracked source: a secret reference is hashed as a fixed token, any other by its value (lock.md).

The last-run report on disk is masked the same way. Each resource's hash takes a secret reference as a fixed [MASKED:NAME] token and masks every registered secret out of any other value, so the report can't be used to test a guess at a secret, and a changed secret value isn't visible to zap status (which says so beside each resource that references one). status rebuilds its masker from its own environment, so for an exact "unchanged since" it needs the same env the run had, secrets included; without one it fails closed to "changed since".

A literal secret in a config is rejected by every command: when a value that is both referenced and resolvable in the current environment also appears written out in the config, the secret gate fails, naming the field and the variable and showing none of the text. It looks for a secret's whole value, 8 characters or more: a shorter one turns up in ordinary text by chance. It is best-effort by design, so it never fails spuriously on a machine that simply lacks the secret.

The lock file and Zap's state #

How Zap pins the evaluator that runs the config and the content it fetches, what zap.lock holds, the commands that move it, and what Zap keeps under ~/.zap. Background: concepts.md.

zap.lock is declared intent, not cached state. It belongs in version control with the config. It records the toolchain pin and the tracked pins; everything else Zap knows about a machine it reads live. The file is the config's name with .lock for its extension, a regular file: a config itself named *.lock is refused (the lock would overwrite it), and so is a symlink where the lock goes, by every read and every write.

The toolchain pin

Every command but zap lock evaluates the config only with the fgro build (Gro's runtime) whose blake3 the lock's toolchain records:

{ "version": 3, "toolchain": { "fgro_blake3": "…" }, "entries": {} }

zap lock writes it: it hashes the binary --fgro or ZAP_FGRO names, evaluates the config with that binary, and records its blake3, whatever the lock pinned before. It prints the pin, and the one it replaced when the pin moved:

$ zap lock pinned fgro d5d10656aab9b1126949c67260afe0f06fbb208230306efd245186f2c4462c22 wrote zap.lock (no eligible resources)

A moved pin reads pinned fgro <new> (was <old>). Every other command reads the pin before it evaluates:

  • no pin, because there is no lock or no zap lock has pinned one in it, refuses the evaluation, exit 2, saying to run zap lock;
  • a binary that hashes to something else refuses, exit 2: run zap lock to pin the build you mean to use, or point --fgro at the pinned one. zap update doesn't re-pin it: moving the evaluator is zap lock's alone, so it shows up in the lock's diff by itself;
  • zap lock --check reports a binary off its pin as a stale toolchain pin, exit 1, and evaluates nothing, so the entries go unchecked until it is pinned.

A build of fgro is not reproducible across machines, so the pin names one binary: another machine evaluates the config only with a copy of the same bytes, or after its own zap lock, which shows in the lock's diff. An unchanged binary isn't hashed in full on every command (On-disk state).

A "version": 1 lock has no toolchain pin: it reads, and every command refuses until zap lock rewrites it. A "version": 2 lock reads as it is; the next write records it as version 3, which a zap build that reads only version 2 refuses. A lock with a version newer than this zap reads is refused, zap lock included.

Two kinds of fetched content

A file with remote content, a build, whose source archive is fetched and built, and an apt_repo, whose signing key is fetched and installed, take bytes from a URL. Each is pinned by the blake3 of the fetched bytes, so the same declaration always resolves to the same bytes. The pin lives in one of two places:

Where the pin livesLock entryHow it moves
Vendoredin the config: blake3: '…' on the remote content or the source, key_blake3: '…' on an apt_repononeedit the hash in zap.ts
Trackedin zap.lock, next to zap.tsone per resourcezap update <id> or zap lock

Vendored is self-contained: a URL and a hash, both in the config, and editing the version means editing the hash. Tracked is the reviewed-upgrade path: bump the version in the config, run zap update, and read the new pin in the lock diff before committing both.

What an entry holds

{ "version": 3, "toolchain": { "fgro_blake3": "…" }, "entries": { "pgdg": { "kind": "apt_key", "config_hash": "…", "blake3": "…" }, "zig": { "kind": "remote_file", "config_hash": "…", "blake3": "…" } } }

kind is remote_file, build, or apt_key. blake3 is the pin, 64 lowercase hex with no prefix. config_hash, in the same form, is the blake3 of the resource's declaration with the pin folded in and Zap's resource-hash version ahead of it: the fixed point the entry was minted for. It is how Zap knows an entry is stale: edit what the hash covers (the URL, a strip_components, a build's run, an apt_repo's source) and the recomputed hash no longer matches, so the pin is no longer known to be the pin for this declaration. It leaves out what doesn't change what the pin is the pin for: the resource's identity (id, name, depends_on), an auth credential, and a file's validate command. A new Zap version that changes what the hash covers reads every entry stale once, until zap update.

The hash is unkeyed. It tells a stale entry from a current one, not a genuine entry from a forged one: whoever can edit zap.lock can write a matching config_hash for any pin. The lock is trusted as reviewed input, like the config beside it, so review its diff before committing it.

zap.lock is committed, so the hash must not be a way to test a guess at a secret. A reference that is a secret (by its use as an auth credential, by mask(), by its name, or by the form of its value) is hashed as the fixed token [MASKED:NAME], whatever its value. Consequences:

  • Rotating a token in a tracked URL doesn't make the entry stale. The lock verifies on a machine that doesn't have the secret when the entry's URL and path hold no non-secret reference, and when the secret is one by more than the form of its value (a variable secret only by that form has to be set).
  • A reference that only looks like a secret (KEYCLOAK_VERSION) loses its staleness signal too. zap lock, zap update, and zap lock --check print a note: naming each reference addressed this way because of its name or the form of its value, with the reason; zap.unmask('NAME') makes a non-secret one count again.
  • Whether a reference is a secret can depend on its value: a URL is one only while it holds a password, and a secret-named variable isn't one while it holds a switch (true, 1). An entry minted under one reading and checked under the other never reads current: it reads stale, or unverifiable when the variable is unset and only its value would make it a secret.

Any other reference is hashed by its resolved value, with every secret value the config references anywhere replaced by its token where it occurs inside it, as the output masking replaces it: a DOWNLOAD_URL that carries the value of a SIGNING_SECRET the config also uses is hashed with that part as [MASKED:SIGNING_SECRET], so its entry moves when the rest of the URL does and not when the secret is rotated with it. A secret shorter than 8 characters, or any form of it shorter than that (trimmed, or with its \n decoded), is not replaced: replacing zig wherever it occurs would move unrelated addresses with the secret's value, and so few characters are guessable anyway. A secret can only be replaced while it is set. While a variable the config uses as a secret (by its use as a credential, mask(), or its name) is unset, zap lock and zap update don't mint an entry whose URL or path holds a non-secret reference, and name the variable to set. Such an entry already in the lock is still checked: an address that matches reads current, and only one that changed reads unverifiable, naming the variable, since the change may be the secret's. A value that carries a secret under an ordinary name (a BASE_URL with a token in it) that no secret reference names should be listed in mask(). A build's run, env values, and verify block are hashed as declared: a reference there counts as the reference, whatever its value, so the entry moves only when the declaration does.

Standing

Every command that reads the lock classifies each tracked resource:

StandingMeaning
currentthe entry's config_hash matches the resource
missingthe resource is tracked but has no entry
stalethe resource changed since the entry was minted
unverifiablea variable the hash depends on is unset: a non-secret reference, or a secret a non-secret value could hold
orphanan entry for a resource the config no longer declares (warn only)

zap lock --check reports the standing as its gate and exits 1 for missing, stale, or unverifiable. The read-only commands (plan, detect, status, the deploy preflight) lead their output with a LOCK banner for the same findings. apply refuses a stale or unverifiable entry instead, as does a deploy wetrun's preflight, with exit 2 (cli.md has every command's exit codes).

Who writes, who reads

Only apply --wetrun and the lock verbs write the file. Each keeps the toolchain pin it found, except zap lock, which sets it. Every other command but zap version validates the file, and refuses one that doesn't parse.

A write takes an exclusive flock on zap.lock itself, opened for writing, so concurrent writers take turns. apply --wetrun and zap update merge into the file as it stands when they hold it, so neither loses entries another wrote; zap lock rewrites the file whole, reading the old one only for the pin it replaces and to refuse one newer than it reads, so it rebuilds one that no longer parses, such as one left with merge-conflict markers. A zap.lock is refused (zap.lock is not writable) for a user who can't write it, rather than replaced, and a lock Zap can't create says its directory isn't writable. A zap.lock that is empty reads as no lock.

  • zap apply: a dry run resolves any missing tracked pin over the network, the one network read a dry run does, and reports it as resolved, not yet written to zap.lock. A wetrun writes the resolved lock before executing, so an apply is never recorded without its intent. A stale or unverifiable entry is an error on apply, never auto-reconciled: the message says what to run.
  • No pin is minted from a URL Zap can't fully resolve. If a tracked URL references a variable that is unset or empty, zap lock refuses (exit 2) and zap update fails that entry, naming the variable; nothing is fetched with the reference blank. A dry zap apply holds the resource instead (exit 1), and a wetrun's env gate has already stopped the run.
  • zap lock: rebuild. Pins the toolchain, re-fetches every tracked source, re-mints every entry, drops orphans, rewrites the file. All or nothing. The only verb that prunes, and the only one that moves the toolchain pin.
  • zap lock --check: offline classification, no network, no detection, after the toolchain pin is checked against the binary.
  • zap update [ids…]: re-fetch the named resources, or all tracked ones. An entry is rewritten when its pin advanced or when its config_hash was stale (same bytes, edited resource), so the zap update <id> the stale error prescribes always clears it. Each advance is written as it happens; a failure later in the run keeps the earlier advances.
  • plan, detect, status, deploy's preflight: read-only. They never mint, and they withhold any pin that isn't current. The resource then behaves exactly like one with no pin: undetermined, held out of the changeset, exit 1 (the preflight bails with exit 2), so a stale pin can't make plan report "converged" for a config apply refuses.

What detection compares

The pin is the desired side of the comparison; detection never fetches. An unextracted remote file is compared by digest against the pinned bytes; an extracted one (extract: tar.*) through its extract sentinel, a .zap_remote_hash file in the destination recording a digest of the tarball pin and the extract form; a build through its provenance sidecar, <output>.zap-build, recording a digest of the source pin and how it is built (extract, run, cwd, env); an apt_repo's key by the blake3 of the key file's bytes against its pin. A pin change re-extracts, rebuilds, or reinstalls the key, and so does an edit to what the digest covers. The details are in detection.md and detection.md.

Verification

A build source that declares verify (minisign) has its signature checked before the archive is cached or pinned, so a tampered fetch never becomes a pin; a cache hit re-hashes the bytes against the pin instead. The fields and the trust argument: resources.md.

What the lock does not cover

Package versions, installer versions, and anything else that isn't a tracked remote file, build source, or apt_repo key. A package is detected by the manager's query through the package manager; an installer by its check path. Drift detection for every other kind is the kind's own live reads.

On-disk state

Zap keeps its own working state under ~/.zap, or $ZAP_HOME, separate from the config and the env file. Either path must be absolute; an empty ZAP_HOME counts as unset. Every directory Zap keeps state in (the root, cache/ and everything under it, ssh/) is yours alone (mode 700) and every file it writes there is 600; bin/ is the installer's, and yours. Zap tightens a state directory it finds looser, and refuses to run (exit 2) over one that is a symlink, isn't a directory, belongs to another user, or can't be opened, since the records in it decide what a wetrun may execute:

PathHolds
bin/zapthe installed binary
bin/fgrowhere install.md suggests keeping the fgro build ZAP_FGRO names, apart from Gro's build output; Zap never writes it
cache/<config-slug>/last_run/<host>.jsonthe last-run report for this config on this host, latest only. A host that isn't a safe file name (a path separator, say) is filed as host-<short-hash>.json. No report is kept for a host that resolves through a masked reference.
cache/<config-slug>/deploy_preview/<host>.jsonthe deploy preview for this config on this host: the plan_hash the last zap deploy dry run (or refused wetrun) showed, when, and which pass. Withdrawn by a deploy wetrun before its first step.
cache/<config-slug>/eval/the eval cache: the config's evaluated JSON beside a manifest of its path, the driver and fgro build it ran on, each file the evaluation read with its blake3, each path a resolution found empty, and the evaluated JSON's own blake3; an entry is served only for the config it was evaluated from, and only the JSON its manifest names. See ZAP_EVAL_CACHE in cli.md; --no-cache bypasses it.
cache/fgro_pins/the fgro binary's blake3, keyed by the file's inode, size and times, so an unchanged binary isn't hashed in full on every command (The toolchain pin)
cache/files/<blake3>/<name>the content cache: bytes for remote files and build sources, by content address, checked against their blake3 on every read
ssh/SSH ControlMaster sockets for remote targets (targets.md)

bin/ is always ~/.zap/bin: cargo xtask install doesn't read ZAP_HOME.

ZAP_FGRO, which names the fgro binary every config is evaluated with, is held tighter than ZAP_HOME: it must be an absolute path, and an empty one is an error (exit 2), not an unset one, since it names what runs your config (install.md). Under sudo, the state home, pin cache included, is the one sudo's HOME gives Zap.

None of this is part of the declared state or the plan hash, and none of it stands in for reading the target. zap.lock is the exception by design: it lives next to zap.ts and is committed, because it records intent.

Detection #

How Zap reads the current state of a target: the verdicts, how deeply each resource kind is read, the batched reads behind every pass, and the last-run report. concepts.md is the short version.

Detection is the detect step of describe → detect → plan → apply → observe. For each declared resource it answers one question, does the desired state already exist?, with one of four verdicts, defined in concepts.md. This page is how each is reached.

A fifth word, residual, describes a resource that still doesn't read converged after a wetrun acted on it. It is covered below.

The governing principle

Detection depth should match execution idempotency. If re-executing a resource is a true no-op, shallow detection is fine. If re-execution has side effects even when the state is already correct (it restarts a service, downloads bytes again, fires a restart_on chain), detection must be deep enough to skip when nothing needs to change.

Detection runs at one of three depths:

DepthQuestionTrust
Existencedoes the thing exist at all?low: misses attribute drift
Attributedoes it exist with the right properties?medium: catches most drift
Contentis the full content or state correct?high: catches all of it

Per-resource depth

KindDepthReads
filecontentexistence, SHA-512 digest, mode, owner, group
linkcontentthe symlink's target, as the login user
buildcontentthe provenance sidecar (below)
swapattributethe file, its /proc/swaps entry, its /etc/fstab line; not size, so a resize isn't drift; a drifted swap repairs only the missing parts
databaseattributethe role, the database, its owner, its pg_hba rules
firewallattributeactive, the default policies, each declared allow rule
apt_repocontentthe key's blake3 against its pin, and the whole list file against the resolved, signed-by source line
serviceattributerunning and enabled (below); config freshness through triggers
directoryattributeexistence; mode, owner, group when declared
userattributeeach declared attribute
packageexistencethe manager's query (which forms compare a version)
installerexistencethe check path; no version or integrity probe
scriptyoursits check command's exit
verifyassertionseach assertion, at detect time (below)
pipelinenonenothing: always absent, so a dry run of a config with one always has a pending change (exit 2), and zap status reads it absent

Notes on the kinds that need them:

  • file
    • A file that exists but can't be read holds as undetermined. So does an unreadable stat when any of mode, owner, or group is declared. Neither reads absent or converged.
    • With a mode, owner, or group declared, the path itself must be a regular file. A symlink there drifts even when what it points at matches, and the write replaces it.
    • The content is compared by SHA-512 digest, so only the digest crosses the wire. See content is compared by digest.
    • One carve-out: an extracted remote file (extract: tar.*) is compared through its extract sentinel, a .zap_remote_hash file in the tree, and the extracted tree is not hashed again. The sentinel records a digest of the tarball pin and the extract form with its strip_components, so a new pin or strip count re-extracts.
    • An extracted tree is read the way the extraction treats it, so a plan never offers a change the wetrun refuses. Nothing at the path reads absent. A symlink, or anything but a directory, at the path holds. The sentinel counts only as a regular file, not a link. A directory without one reads absent when it is empty, and otherwise holds: Zap didn't extract it, and won't replace it.
  • link: read as the login user. A path that isn't a symlink, or one the login user can't reach, reads absent, and a regular file there is replaced by the link on apply; a directory there fails the step. See how detection reads the target.
  • database
    • One psql -X query as the postgres role reads the first three and the hba_file setting. -X means no ~/.psqlrc adds to its output.
    • Each declared rule is then a grep -qE of that file. No matching line reads the rule as missing, and a file grep can't read holds.
    • The query runs under a timeout of its own (50 seconds), which stops psql itself and not only the shell that started it.
  • firewall
    • A rule is matched against the whole rule target in the live ruleset, never a substring, so allow 80 is not satisfied by an 8080/tcp line, and a default-policy flip made out of band drifts.
    • ufw: a rule counts only as the row ufw allow writes, an inbound ALLOW from Anywhere, matched on its parsed To column in ufw status verbose (verbose for the Default: line). A named profile matches either its bare name or the verbose <ports>/<proto> (<name>) rendering, multi-word names like Nginx Full included. So an out-of-band ufw deny 22/tcp, an outbound allow, or an allow from one address drifts. The status is read in the C locale so its prose parses the same whatever the operator's locale. A status with no readable Default: line holds, and does not read the policies as converged.
    • nftables: the read is nft list table inet filter. A rule counts only as a whole rule line of the input chain, set aside only what doesn't change what it admits (a counter and its totals, a comment, a # handle). So ip saddr 1.2.3.4 tcp dport 22 accept or iifname "lo" tcp dport 22 accept doesn't satisfy a declared 22/tcp. Each base chain's effective policy is read: the policy verb refined by the trailing bare reject rule, so a desired reject is distinguishable from drop. A missing input or output chain drifts as missing.
    • Allow rules are additive: each declared rule must be present, but a rule added out of band is neither detected nor removed.
  • directory
    • An unreadable stat holds as undetermined when any of mode, owner, or group is declared. With none declared, existence is the whole desired state.
    • Existence is read as the login user and follows a symlink (test -d semantics), so a directory the login user can't reach reads absent.
    • With a mode, owner, or group declared, a symlink to a directory drifts, and the change fails without touching what it points at.
    • The mode, owner, and group are read only when one of them is declared: as root over SSH, and as the operator locally.
  • user
    • With home_mode or ssh_keys declared, the account's /etc/passwd home is read: a home other than /home/<username>, or none, holds the user, since Zap acts on paths under /home/<username> (resources.md).
    • With either declared, what is at /home/<username> is read with a stat that doesn't follow a link, for an account that doesn't exist yet too: nothing there, or a directory the user or root owns, reads on (an existing account's root-owned home is left as it is; the creation of an absent one gives it to the user). Any other owner, a link, or anything but a directory holds the user. A /home that is missing, or that the connecting user can't search, holds the user naming /home, rather than reading as nothing there.
    • SSH authorized_keys and sudoers, compared by content with surrounding whitespace trimmed. Up to 1 MiB of authorized_keys and 64 KiB of sudoers are read; past that the file drifts.
    • Supplementary group membership (id -nG). It is additive: a desired group that isn't present drifts.
    • An ssh_keys account's /etc/shadow password field is held authoritatively at the disabled *. Any other value drifts: a ! lock, an empty field, or a hash.
    • A probe of a declared attribute that exits nonzero (a stat of a missing home, with home_mode declared) holds the user: the attribute wasn't read, so it isn't called converged. An attribute the config doesn't declare isn't read at all. A key file that can't be read is drift.
    • A copy_from source's authorized_keys is found through that user's /etc/passwd home and read as that user, the way apply reads it, so a source file that user can't read drifts on every plan and fails on every apply.
  • package: a package held back, or installed for any architecture, is installed; one half-installed or removed with its configuration left is not. A query that fails holds the package.

The shallow rows are deliberate, not unfinished. A package install or an installer re-run is idempotent, so existence detection is enough. script hands the depth to you: make its check precise or it runs on every apply.

build: exact provenance, not a version string

A build is content-addressed by the blake3 of its source archive, the pin: the vendored source.blake3, or else the zap.lock entry. After a successful build Zap writes a <output>.zap-build sidecar recording its provenance: 64 lowercase hex, the blake3 of a fixed tag, a format version, and the pin with the build's extract, run, cwd, and env as declared. Detection computes the desired provenance the same way and compares:

What detection findsVerdict
output or sidecar missingabsent
sidecar differs from the desired provenancedrifted: the upstream moved or the build changed, so rebuild
sidecar equals the desired provenanceconverged
no usable pin (a tracked source with no lock entry, or a stale one)undetermined in plan, detect, status and a deploy's preflight (which bails, exit 2); apply mints a missing pin and refuses a stale one (exit 2)

A reference in run or env is part of the provenance as written, not as resolved: a secret's value never reaches the target, and a new value for a variable alone does not rebuild.

This is why a --version check isn't the drift signal. The provenance is exact and language-independent, so a moved upstream is caught even when the installed binary still runs. See resources.md.

verify: assertions at detect time

A verify resource runs its assertions during detection. If all pass, it reports converged and is skipped. If any fail, it becomes a pending change; on a wetrun its assertions run again at its place in the dependency order, and one still failing fails the run there, so an earlier change in the same run can satisfy it. A dry run shows it as a pending change and runs nothing. A verify never executes anything of its own. It is a guard rail, and changes ordered before it have already run, so order it with depends_on to put the guard where it belongs. Because the answer is live state, a verify is checked again on every pass.

For a gate that should run only after something else applies, use a script with check and run, ordered by depends_on. That keeps depends_on meaning ordering, not a detection special case.

Service probes

A service's two probes read what the manager reports, and a probe that fails to read holds the service: a failed read is no evidence of a stopped or disabled service, and reading it so would take running: false or enabled: false as converged.

  • systemd: systemctl is-active exiting 0 is running, 3 (inactive or failed) and 4 (no such unit) not running; systemctl is-enabled exiting 0 is enabled, and a non-zero exit is not enabled only when it prints disabled, masked, masked-runtime, linked, linked-runtime, or not-found.
  • OpenRC: rc-service <svc> status exiting 0 is started, 3 (stopped, or no such script) and 32 (crashed) not running. 64 (unsupervised: a supervise-daemon service whose supervisor died, its daemon perhaps still running) holds, and the reason says to check for the daemon and run rc-service <svc> zap by hand. Enabled is an entry for the service in any runlevel under /etc/runlevels, as rc-update add <svc> <runlevel> makes; a disable removes it from each runlevel it is in.

Any other exit holds the service as undetermined.

Service triggers close the config-freshness gap

Service detection checks running and enabled, not whether the service's config is fresh. The trigger model closes that gap by reacting to changes, not by deepening detection: a file lists triggers (push), or a service lists restart_on or reload_on (pull). When the referenced resource changes, the planner promotes the service from skip to a restart or reload.

Holding: the undetermined verdict

When Zap cannot establish current state, it refuses to guess. The usual causes are a probe that couldn't be reached, a remote path whose target home wasn't declared, a path or name whose reference resolves to a value Zap won't use (one holding a reference delimiter, a path that isn't absolute or spans lines, a database name or host a pg_hba.conf line would misread), and, in plan, detect, status and a deploy's preflight (which bails, exit 2), a tracked pin with no lock entry or a stale one (apply mints a missing pin and refuses a stale one, exit 2). The planner holds the resource and does not propose a create that might clobber live state. The next pass reads it again: no verdict carries over from one pass to the next.

A held resource contributes no change, so it simply isn't in the changeset. An empty plan would hide it, which is why every command that compares reports the hold as a verdict:

  • plan and apply (dry or wet) show an UNDETERMINED banner naming each held resource and why, and detect marks each one ?. All three exit 1: "converged" is a claim about the target that was never established for the resources nobody could read, and for a dry apply 1 outranks 2 ("drifted") too.
  • deploy's drift preflight bails (exit 2): a gate that couldn't check everything must not pass by default.

Exit codes in full: cli.md.

A hold spreads to what can't run without it

A change that waits on a held resource is held behind it: it could act on state the held resource was meant to set up. This follows depends_on forward, including the implicit edge from a service to its trigger sources, and passes through a converged resource unreported, since that resource changes nothing. A change that would restart or reload a held service is held too: its change would owe the service the action with nothing to run it, so the action would never happen. This follows trigger edges in reverse (a file's triggers, a service's restart_on and reload_on), and never a plain depends_on: what a held resource depends on still runs. The two rules repeat until nothing changes, so a source held for its trigger also holds what waits on it, and a source that is itself a service holds its own sources.

One exception: a converged service whose only change is the restart or reload a pending marker owes waits only on what it declares, so a trigger source detection couldn't read doesn't hold it; the owed action runs. A source that changed and is held behind another still holds the service its change fires.

The banner lists the resources detection couldn't read first, then the ones held behind them, each with what it waits on or would trigger:

UNDETERMINED — 1 resource(s) could not be probed, so no change is planned for them and this changeset is partial: web — detection failed: the unit read exited 1 their live state is unknown: neither converged nor drifted. resolve the probe failure and re-run. held behind them — 2 resource(s) can't change until they do: after — waits on `web`, which is held site — would reload `web`, which is held

Resolving the probe failure releases the whole chain on the next pass.

undetermined is about the observed side. Its twin on the desired side is the unverifiable verdict (env.md), where the declaration is the half that couldn't be built.

Kinds that read a failed probe as absent

A few detectors deliberately map a failed probe to absent:

  • firewall, on both backends;
  • script, when its check fails, and a verify assertion that fails;
  • link, directory, and swap, for a path the login user can't reach (and, for a link, one that isn't a symlink).

For these, a probe failure almost always means "not set up yet" (the fresh host, before the same config installs ufw or nftables), and their create steps are idempotent and identical to their update steps, so a misread probe either re-asserts the declared state or fails loudly at execution. The one consequence to know: a regular file sitting where a link is declared reads absent and is replaced by the link (a directory there fails the step instead).

A file is the opposite case and holds. "Exists but unreadable" proves there is state a create would clobber, which is the harm the hold exists to prevent.

database splits the two cases:

  • No PostgreSQL reads absent. That means no psql, or no postgres account (a client-only host). Both are checked before the query runs, in the same root environment the query runs in, so this is a detected fact. Zap's probes and steps run as postgres, and there is no cluster they could reach, so the fresh host's first apply creates it. A cluster run under another account name reads absent too; its create steps then fail loudly under sudo -u postgres.
  • A failed query holds. With both present, a query that fails (the server is stopped, or postgres can't authenticate) reads nothing about the role, the database, or its pg_hba rules. Output that isn't the query's one row also holds, and so does a pg_hba.conf the server names that the postgres account can't read: the rules are read as that account, never as root.

The residual verdict

A wetrun closes the loop on the other side too. After executing, apply re-detects the target. This is the observe pass, and it becomes the last-run report. Any resource that still doesn't read converged is reported as the residual verdict, exit 1.

A change can exit 0 without converging its resource: a script whose check still fails after run, or a mutation whose probe reads back differently. So "applied: N ok" alone is command success, not convergence. The residual banner is what makes the wetrun's final claim an observed fact. If the re-detection itself fails (connectivity dropped after execution), the apply reports that and still exits 1, because "converged" was not established.

Three cases are exempt, because "not converged" is impossible or already reported more loudly:

  • a pipeline, which detects absent by construction;
  • a change that failed, or was skipped by stop-on-first-failure, which is already the run's headline and exit 1;
  • a held resource, which is already the undetermined verdict.

A script runs its check twice per wetrun, once detecting and once verifying convergence. That is the price of its user-defined depth.

How detection reads the target

Every kind detects through batched queries. (A pipeline reads nothing: it always runs.) A kind declares the reads its verdict needs as typed queries, and a pure function turns the answers into the verdict. The function sees nothing but those answers, so a detector can't read anything it didn't declare. The queries are:

  • the type of what a path resolves to;
  • a path's own mode and ownership;
  • a symlink's target;
  • the SHA-512 of a file's bytes;
  • the first few bytes of a small file;
  • a command Zap owns: a package manager's query, a service manager's probe, an awk match in /proc/swaps, ufw status verbose, a psql query;
  • an operator's command: a script's check, each of a verify's assertions, a package's check that is a command.

The pending-trigger markers (resources.md) are read the same way, as one more query of the plan's pass.

Every batched query is known before anything is read from the target: from the config, zap.lock, and the operator's content cache. (An uncached remote file's bytes are read after the batch; see below.) The two reads that depend on another read's answer compose in the target shell, inside one command: a database's pg_hba greps run against the hba_file its catalog query just reported, and a copy_from user's source authorized_keys is read from the home its /etc/passwd entry names.

Privilege

Every query names the privilege its read runs under. It is the privilege the equivalent single-command probe would have, so a kind sees the same thing whether it is read alone or in a batch.

  • login: as the connecting user, unprivileged, on every connection. Read this way:
    • a directory's existence and a link's target;
    • a package manager's query and a service manager's two probes;
    • the swap file's test -f and its /proc/swaps and /etc/fstab matches;
    • an apt_repo's source line, resolved against /etc/os-release;
    • a user's id -u, /etc/passwd shell and home, what is at its home and who owns it, the home-directory mode, and id -nG;
    • a verify's assertions: a path_exists chain of test and stat (Zap's own command; one under a directory the connecting user can't search fails) and a command's run;
    • the other operator's commands: a script's check and a package's command check;
    • the pending-marker read.
  • root: as root on every connection. It is the privilege of a probe that carries its own sudo, written without it. A local target runs these through sudo. Read this way:
    • a firewall's status (ufw status verbose, nft list table inet filter);
    • a database's catalog query and pg_hba greps, each run as the postgres account through sudo -u postgres from root;
    • a user's /etc/shadow password field and sudoers test -f;
    • a copy_from source's authorized_keys, read as the source account through sudo -u from root;
    • a user's own authorized_keys, read as that user through sudo -u from root, the account the file belongs to and is written as. A symbolic link or anything but a regular file there is not read; it drifts, and apply writes the file in its place.
  • elevated: as root over SSH, where the login user isn't assumed to own what Zap manages. On a local target the read is tried as the operator first, directly. A read that fails any way other than the path not being there (a root-only parent, a file only root can read) is asked again as root, through sudo, in one batch for all of them. A local config that touches only the operator's own files never invokes sudo. Read this way:
    • every read of a file: its existence, content, and mode, owner, and group;
    • a directory's managed mode, owner, and group;
    • a package's or an installer's check path;
    • an apt_repo's key and sources list;
    • a build's output and provenance sidecar;
    • a user's sudoers contents.

Because a file's content is compared by digest and not read as text, a file whose bytes aren't valid UTF-8 reads like any other. It doesn't send a local read to sudo.

A content read (a digest, or a bounded read of a sentinel or sudoers file) opens only a regular file of its own. A symbolic link at the path, dangling or not, is not followed, and a directory, a device, or a fifo is not opened: at root's privilege, opening a device can act on the machine, and a fifo holds the read until its timeout. The read reports the path as not a regular file instead, and each resource says what that means: a file whose path is a symbolic link to a regular file drifts, since its write replaces the link with the file; anything else there that isn't a regular file holds it. A regular file with more than one hard link, its link count read from the descriptor, is read by no content read and holds the resource, saying to replace it with a copy of its own (a single link): a hard link is the file under another name, which could make a root read of the declared path read a file someone else linked there. A name swapped between the check and the open is opened as it then is, under the read's timeout.

Content is compared by digest

A file's content is never shipped back to compare. The target runs sha512sum over the file and only the digest crosses the wire, however large the file. What the digest is compared against depends on the content:

  • inline and template: the SHA-512 of the rendered content. Inline content and a template's body are compared as declared; a template's vars have their references resolved from the operator's environment.
  • source: the digest of the source path, read in the same batch as the target's. A source that can't be read, or that is not a regular file of its own (a symbolic link included), holds the resource.
  • remote, extract: none: the SHA-512 of the pinned bytes, taken from Zap's content cache (~/.zap/cache/files/<blake3>/<name>, where fetching them put them), after checking that those bytes still hash to the pin.
    • The pin is the declared blake3, or else the zap.lock entry. With neither, or with a stale lock entry, the file holds in plan, detect, status and a deploy's preflight (which bails, exit 2); apply mints a missing pin and refuses a stale one (exit 2).
    • When the content cache doesn't hold the pinned bytes (a fresh ZAP_HOME, another machine), Zap reads the target's bytes directly, after the batch, and compares their blake3 to the pin. Only a regular file of its own is read: a symbolic link or anything else at the path holds the resource. When they match, it stores them in the content cache, so the next pass compares digests.
    • Detection never fetches the url.
  • remote, extract: tar.*: the extract sentinel's first 4 KiB, compared, with surrounding whitespace trimmed, to the desired provenance (the digest of the pin and the extract form). A sentinel longer than that drifts.

A drifted remote file reports what it observed: the target's blake3 when its bytes were read, or its SHA-512 when only the digest was. The message is remote content differs from its pin (observed sha512 …), and --diff shows it as a replacement of the content pin, not as a pin of its own.

Batches

Over SSH, all of a pass's login reads run as one shell script in one command, and all of its root and elevated reads as a second, under sudo: the login and root lanes. The two run concurrently. A detection pass costs one round trip per lane, however many batched resources the config declares. A target missing a tool a lane's batch needs, or the /proc/self/fd its content reads check what they opened through, fails that whole lane.

Connectivity. The batches are also the pass's connectivity check. An ssh that can't reach the target (it exits 255, or can't start) fails the command with a cannot connect error before any verdict is printed. The error carries ssh's own message, with a hint when the host key changed. A pass with nothing to batch checks connectivity with an ssh true of its own.

One authentication. A pass authenticates once. When no connection to the target is already open for Zap to share (its ControlMaster), the login batch waits for the root batch to be under way and shares the connection it opened, so no second one is opened.

What adds a round. Two things:

  • an unextracted remote file whose pinned bytes aren't in the content cache. Its bytes are read with a command of their own, after the batch. See content is compared by digest.
  • on a local target, the elevated reads the operator was denied. A local target answers login and elevated path reads natively, without a shell, and asks the denied elevated ones again as root in one sudo batch after that. Its login command reads run as one local batch, as the operator, and its root reads run in the root batch.

Local sudo. A local root batch runs under sudo -n, after Zap has made sure sudo won't prompt; operator commands run with no terminal, locally as over SSH (targets.md). If sudo won't authenticate, every resource with a read in that batch holds, with the fixed reason sudo authentication failed.

The script travels on stdin, never the argv, and each probe's output comes back framed, so bytes a probe prints (newlines and NULs included) can't be mistaken for another probe's answer, and nothing a probe prints reaches the stderr Zap shows when ssh itself fails. A probe's exit status travels apart from its output, so nothing the probe writes can forge it; a command runs in a session and process group of its own, so a command that signals its group reaches only itself. The target's sudo log records one constant wrapper per lane (security.md).

Locale. Zap's own probes run under LC_ALL=C, so what they print parses the same on every target. An operator's command runs in the target's own locale, as it would by hand.

Operator commands (a script's check with its references resolved, its cwd and env) travel inside the script too, so a resolved value is on no argv. The check's exit decides: 0 is applied, anything else (a cwd that can't be entered and a check that doesn't parse included) is absent. The script holds only when the check produced no exit status (a timeout, a failed lane) or a reference can't be carried.

What the target needs. timeout, od, tr, head, cat, stat, and readlink; sha512sum when the batch reads a digest; ls when it reads whether an extracted tree is empty; and setsid and sleep when it runs a command. Over SSH, setsid is needed in any case. These come from GNU coreutils and util-linux, or BusyBox (validated on 1.37). A target missing one fails the batch, and does not read every path as absent. Writes need a few more (targets.md); a write that lacks one refuses, naming it, with nothing changed.

Output limit. Zap reads at most 64 MiB of a batch's output. Past that it stops the batch and holds every resource in it.

Local content reads. A local target answers content reads natively too, except of something that is neither a regular file nor a directory (a fifo, a device), which its probe answers under its timeout.

What a read can conclude

Commands answer with their exit status.

  • An operator's command: a script's or package's check exiting 0 is applied or installed, and anything else is not. A verify assertion holds when its command exits 0 and, with an expect, its stdout contains it.
  • A command Zap owns: the package manager's query and the service manager's probes exit 0 if and only if the state holds; test -f, the swap's awk match, and a pg_hba grep exit 0 if and only if the file or line is there. The package manager's query answers "not installed" with exit 1 alone: any other status is a query that failed (dpkg-query unable to read its database, or missing), and the package holds.

Some commands also print what they read (a firewall's ruleset, a user's shell or groups, a database's catalog row), which the verdict parses. A failure means what it meant to that probe: a firewall probe that fails needs setting up, and a user attribute probe that fails holds the user.

Otherwise, what holds is an answer that is missing (a timeout, a garbled record, a failed batch) or output that doesn't parse. A database also holds when its catalog query failed with PostgreSQL installed, or when its pg_hba.conf couldn't be read.

A path read concludes "nothing there" from one failure only. That is the failure stat and readlink report for it (their exit status 1), and the content reads, which open the path with a redirect that exits 1 when it can't be opened. It means the path is absent, can't be reached at the read's privilege, or, for a symlink's target, isn't a symlink. For a content read, it means the path can't be read (a directory); for an extracted tree's emptiness read, that it isn't a directory the read's privilege can list. A file that exists but whose content read finds nothing holds, and does not read absent.

Anything else holds the resource as undetermined and does not guess:

  • a probe that failed another way (it couldn't run, or was killed);
  • output that doesn't parse (an owner or group name containing whitespace);
  • a missing or garbled answer.

A resource needs only the answers its verdict reads: the mode of a directory that doesn't exist is never consulted. But a batch that failed as a whole holds every resource with a read in it, with a fixed reason that quotes none of the batch's output. That covers a dropped connection, a refused sudo, output that stopped before its end marker, and a missing tool. So does a pass whose answers don't line up with its reads one for one (a layout mismatch): no answer in it is read, and the reason names the read left without one. None of these ever reads as absent.

Timeouts

Each probe runs under timeout and reads undetermined ("timed out after 10s") when it runs out:

ReadLimitWhy
Zap's own path reads10 secondsa healthy stat takes milliseconds; a slow one is a hung mount
the mode and ownership read30 secondsit resolves owner and group names, which may go through a network name service
a digest60 secondsit reads the whole file
a command, Zap's own or an operator's60 secondsit may do real work
a database's catalog query, inside its command's limit50 secondsits own timeout stops psql, and leaves the pg_hba greps room

Zap's own commands are a package manager's query, a service manager's probe, a firewall's status, and a database's catalog query with its pg_hba greps.

A command's timeout stops its whole process group, children included, with SIGTERM and then SIGKILL a second later. A command that exits 124 or 143 on its own reads as that exit; what reads as timed out is a command with no report. Because something can outlive a probe's timeout (a process that left the command's group, or one a command backgrounds), each batch also runs under a deadline, the sum of its probes' limits plus a margin. When that passes, Zap stops the batch, kills what it can of it on the target, and holds every resource with a read in it, so a stuck probe costs at most the deadline.

Nothing is cached

Every pass reads every resource. A stored answer is sound only when nothing outside Zap could have changed the target since it was read, and Zap never assumes that; and a pass costs one round trip per lane however many resources the config declares, so a stored answer would save nothing.

That matters most for the controls people change by hand: security.md.

The pending-trigger markers (/var/lib/zap/pending/) follow the same rule: they are read on every plan for a config that declares a trigger, in the same batch as the plan's detection reads (resources.md).

The caches Zap does keep are on the operator machine and never stand in for the target: the config eval cache, whose manifest is validated again on every lookup, and the content cache, whose bytes are checked against their blake3 on every read (lock.md).

The last-run report

Every detection pass records what it read in a last-run report, and zap status renders it offline. The passes that write it:

  • detect and plan;
  • apply: a dry run's pass; a wetrun's pass before its changes, then its re-detection after them, which is the last word; and a refused wetrun's pass, recorded as the preview it was;
  • deploy's drift preflight.

Detection never reads it. The wetrun's review gate reads only which pass wrote it and its plan_hash.

The report is latest-only, one file per config and host, under ~/.zap (lock.md). It holds:

  • when the pass ran (RFC 3339, UTC), which command pass it was, and the host (local, or the resolved remote host);
  • the names of any references the pass's comparison couldn't resolve, names only. This is the pass's unverifiable finding.
  • per resource read: its verdict (converged, drifted, absent, or undetermined), its hash, and whether it references a secret. It never holds the observed state, an undetermined reason, or anything else the target returned.
  • the pass's batches: each one's lane and query count;
  • the plan_hash of the plan the pass computed, when it planned (detect doesn't). For a pass that previewed that plan (zap plan, a dry zap apply, a refused wetrun), this is the reviewed plan the next zap apply --wetrun binds to (concepts.md).

What a resource's hash covers

A resource's hash covers its declaration as written, less its identity (id, name, depends_on) and a fetch credential; the value of each reference in a field that takes one; and the pin zap.lock holds for it. A reference resolves in one of two ways:

  • a secret one (env.md) resolves to a fixed [MASKED:NAME] token, so a DATABASE_URL holding a password hashes as its own token;
  • any other resolves to its value, with every registered secret in that value masked.

So a changed value of an ordinary variable, wherever the resource takes a reference (a script's check, a path, a template var), reads as a changed config. A verify command is hashed as written.

What the hash excludes is the value of every registered secret, whether referenced by name or carried inside another variable. So the report can't be used to test a guess at one, and a changed secret value can't be seen offline. There is one exception to the first: a short secret that also happens to occur inside a known non-secret value, where whether the masker masked that value depends on the secret.

A value the masker doesn't know as a secret is hashed as it is. That means an unregistered name, or text written literally into the config, which the secret gate rejects when it can.

The host a report is filed under is always a resolved value: a host that resolves through a masked reference keeps no report, every pass says so (UNRECORDED), and zap status reports that none is kept and exits 1. The report also records a target hash over the whole zap.target(...) (host, users, port, key path, home, package and service manager), its references resolved the way a resource's are, since every verdict was read against it. A file that isn't a report of the current format version reads as no last run.

Glossary #

One line per term, with where it's explained. The model itself is in concepts.md.

Absent — a detection verdict: the resource isn't present; the planner creates it. concepts.md

Additive — a declared list where each entry must be present and an entry added by hand is neither detected nor removed: firewall allow rules, pg_hba rules, a user's groups. concepts.md

Apply — zap apply: a dry run of the plan by default; with --wetrun, the command that changes the machine. cli.md

Authoritative — a field made exactly the declared value; anything added by hand is removed, not merged. One of three field classes, beside additive and unset. concepts.md

Batch — one shell script per lane carrying every detection read of a pass, delivered on stdin, run once per pass. detection.md

Bootstrap user — target.bootstrap_user, the alternate account --bootstrap connects as for a first provisioning run before SSH hardening locks it out. targets.md

Build — the build resource: fetch a source archive, verify and pin it, build it, record its provenance in a sidecar. resources.md

Changeset — the resources the plan will create or update, with their steps. What plan_hash names. concepts.md

Converged — a detection verdict: the desired state is already present; nothing to do. Also the state of a machine whose every resource reads so. concepts.md

Convergent — the control model: read the current state, diff it against the declared state, change only the difference. Re-running is always safe. concepts.md

Declaration — the JSON a config evaluates to: the target plus every resource. A pure function of zap.ts and the files it reads. configs.md

Deploy — zap deploy: ship a versioned release to a remote host as releases/<version> plus a current symlink, restart, health-check, roll back on failure. The one imperative command. deploy.md

Depth — how much detection reads for a kind: existence, attributes, or content. Matches the kind's execution idempotency. detection.md

Drift — the gap between the live machine and the declaration. Found by reading, every run; never tracked. concepts.md

Drifted — a detection verdict: present but wrong, carrying what was observed; the planner updates it. concepts.md

Dry run — apply or deploy without --wetrun: reads the target, renders the plan, changes nothing on it. concepts.md

Elevated — a detection privilege: root over SSH; on a local target, the operator first and sudo for a read that failed. detection.md

Env file — a file of NAME=VALUE lines loaded with --env-file, the source of reference values alongside the environment. env.md

Extract sentinel — the .zap_remote_hash file an extracted remote file leaves in its destination, recording a digest of the tarball pin and the extract form. lock.md

Gates — the machine-free checks every command runs on the declaration: secrets and structure. configs.md

Held — what happens to an undetermined resource: left out of the plan, reported, exit 1. A change that depends on a held resource, or would trigger a held service, is held behind it. concepts.md

Imperative — a resource or command that always runs because there is no stable state to converge to: pipeline, deploy. concepts.md

Lane — one of a pass's batches, named for what it runs as: login, or root. A lane is not a privilege: the root lane carries both the root and the elevated reads. detection.md

Last-run report — what the most recent detection pass on a host read, kept under ~/.zap and rendered by zap status; a preview's report holds the plan a wetrun binds to. detection.md

Lock standing — a tracked pin's classification against the config: current, missing, stale, unverifiable, or orphan. lock.md

Login — a detection privilege: the connecting user, unprivileged. detection.md

Mask — removing a resolved secret's value from every output. zap.mask() / zap.unmask() say so for a reference the rule reads wrongly. env.md

Operator — you: the person (or agent) running Zap, whose environment supplies reference values and whose SSH keys reach the target.

Orphan — a last-run resource, or a lock entry, that the config no longer declares. Warn-only. lock.md

Pass — one detection of every declared resource against the target: every read batched, every resource read live. detection.md

Pending trigger — a marker under /var/lib/zap/pending/ on the target recording a restart or reload a run owes a service. concepts.md

Pin — the blake3 of fetched bytes, declared in the config (vendored) or recorded in zap.lock (tracked). lock.md

Plan — the changeset for one run against one target, and the command that renders it. concepts.md

plan_hash — the blake3 that names a plan for review and for --expect-plan. It covers the changes, the machine they are for, what each reference resolves to (a secret only as the fact), and the text Zap's own steps run. concepts.md

Preflight — the convergence pass deploy --wetrun runs before shipping; drift or an unreadable resource refuses the release. deploy.md

Preview — a pass that rendered a plan and executed nothing: zap plan, a dry zap apply, a refused wetrun. Its plan is the one a wetrun binds to by default. zap deploy keeps its own, the deploy preview (deploy.md). concepts.md

Provenance sidecar — the <output>.zap-build file recording a digest of the source pin a build came from and how it was built. lock.md

Recipe — a shipped pure function returning a config fragment. recipes.md

Reference — zap.env('NAME'): a stand-in for an operator-environment value, inert in the declaration, resolved when Zap acts, shown as {env:NAME}. env.md

Refused — a wetrun that didn't execute because its plan wasn't the reviewed one, or, asked on a terminal, wasn't confirmed (declined). Exit 2; the plan is rendered and becomes the preview. concepts.md

Residual — a resource that still doesn't read converged after a wetrun's changes ran. Exit 1. concepts.md

Resource — one typed unit of desired state with a stable id. resources.md

Reviewed plan — the plan a wetrun must match to execute: the one named by --expect-plan <hash>, or the one in the host's last-run report when that pass was a preview. concepts.md

Root — a detection privilege: root on every connection. detection.md

Target — where a config acts: local: true, or a host over SSH, with its package and service managers. targets.md

Template kind — the form a template variable's value is held to (hostname, port, path, …), checked on its whole rendered value: a hostname in an nginx server_name can't end the directive. resources.md

Toolchain pin — the fgro build a config is evaluated with, recorded in zap.lock by zap lock; a missing or mismatched pin refuses. lock.md

Tracked — a remote file, build source, or apt_repo signing key with no pin in the config; the pin lives in zap.lock. lock.md

Trigger — a file's triggers, or a service's restart_on / reload_on: a change in the source promotes the service to a restart or reload. concepts.md

Two-tier privilege escalation — privileged commands carry an explicit sudo; on a local target file operations try natively and escalate only when denied, and over SSH they run under sudo. targets.md

Unbindable — a plan whose host, connecting user, or deploy health domain resolves through a masked reference: it has no plan_hash, so no wetrun can bind to it. env.md

Undetermined — a detection verdict: Zap couldn't read the resource and won't guess. Held. concepts.md

Unset — a field the config leaves out: unmanaged, neither set nor reverted. concepts.md

Unverifiable — a comparing pass whose desired side depended on an unset reference. Exit 1. env.md

Vendored — a remote file, build source, or apt_repo signing key pinned by a blake3 in the config itself (an apt_repo's key_blake3). No lock entry. lock.md

Verdict — detection's answer per resource: converged, drifted, absent, or undetermined. concepts.md

Wetrun — --wetrun: the flag that makes apply or deploy act. Zap's word for "not a dry run", chosen to be hard to type by accident. concepts.md

Withheld — a tracked pin that isn't current, dropped before detection by the read-only commands so its resource holds. lock.md

zap.lock — the pin ledger next to zap.ts, committed with it. lock.md

~/.zap — the operator-side state directory (or $ZAP_HOME): caches, last-run reports, deploy previews, SSH sockets; the installed binary stays in ~/.zap/bin. Never part of the plan. lock.md

Contributing #

How to build, check, and test Zap when you are changing it. The project-wide guidelines and code of conduct are at fuz.dev/contributing. To install Zap for use, see install.md.

Builds

CommandProduces
cargo xtask installa release build at ~/.zap/bin/zap, the binary on your PATH
cargo build -p zapa debug binary at target/debug/zap
cargo xtask build-muslstatic binaries for x86_64 and aarch64 Linux at target/<triple>/release/zap, with no glibc dependency (needs zig and cargo-zigbuild); evaluating a config still needs an fgro that runs on that machine

Checks

cargo clippy --workspace --all-targets -- -D warnings # the engine's lints cargo test --workspace # the engine's suite gro check # the site and the TypeScript surface cargo fmt -p zap -p zap_core -p zap_types -p xtask # format the engine cargo xtask check-release # production binaries link no test or audit crate RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps --all-features --document-private-items # the docs build

CI runs each of these as a gate, a warning from clippy or rustdoc included, and runs the suite a second time under umask 000, since a mode the tests assert is only as strict as the umask their files are made under.

Run npm ci before gro check. After an engine change, run cargo xtask install: the zap on your PATH is whatever was last installed, and a stale one mis-plans in ways that look like config bugs.

Format with the packages named, never cargo fmt --all: rustfmt follows the path dependencies into the sibling fuz checkout and would reformat its files.

The hermetic suites

Nothing in them reaches a remote host. A test that runs an external program checks nothing where the program is missing, so each crate's required_programs test fails there instead, unless ZAP_TEST_ALLOW_NO_<NAME>=1 says the absence is expected:

CratePrograms
zapfgro (ZAP_FGRO), git
zap_corefgro (ZAP_FGRO), busybox, git, setfacl, getfacl
xtaskopenssl, curl, busybox

fgro is the one not looked up on PATH: the tests that evaluate a config run the build ZAP_FGRO names, by absolute path (ZAP_FGRO=/abs/path/to/fgro cargo test --workspace). The site's zap config validate e2e needs ZAP_FGRO and a built target/debug/zap (cargo build -p zap), and skips without them. Legs that need fakeroot run only where it is installed; a missing one is noted on stderr (--nocapture) and the test passes.

CI has no fgro build to give the suite, so the tests that evaluate a config, and the e2e, don't run there: CI sets ZAP_TEST_ALLOW_NO_FGRO=1, and the e2e reports itself skipped. Run them locally with ZAP_FGRO set before you push a change to config evaluation.

Validating against real machines is separate: testing.md for the VMs, differential.md for the harness that compares two zap builds on them.

Adding a resource kind

A new kind touches every crate; the module maps are crates/zap_types/CLAUDE.md and crates/zap_core/CLAUDE.md.

  1. Schema: in crates/zap_types/src/schema/, the ResourceKind variant (mod.rs) and its payload struct (resources.rs). tests/fidelity.rs guards the round trip.
  2. Detection: crates/zap_types/src/observe/<kind>.rs, typed read-only queries and a pure interpret returning a Detection, wired in Observed::for_resource (crates/zap_core/src/resource/detect/observed.rs; the match is exhaustive, so a missing arm fails to compile). Every kind is read live on every pass.
  3. Execution: the kind's arm in change_commands (crates/zap_core/src/resource/execute.rs; the match is exhaustive), which run_change's match dispatches on: a kind with command steps runs them, a native one gets an arm in run_native, and file and build have their own executors.
  4. Steps and hashing: crates/zap_types/src/plan/steps/<kind>.rs, with inline command goldens, and the field selection in crates/zap_types/src/config_hash.rs.
  5. Builder: src/lib/zap.ts, src/lib/schema.ts, and the driver (crates/zap_core/src/config/builder_fgro.js). Mirror the change in builder.js beside it; the drift tests fail otherwise.
  6. Tests: detection goldens (crates/zap_core/tests/fixtures/detection/) and the fidelity round trip.
  7. Docs: the kind's entry in resources.md.

Recipes

The shipped recipes follow the design rules in recipes.md.

Testing against real targets #

A contributor's page: how to validate Zap against a real target. For a first run against your own machine or a server, see getting-started.md.

This page covers what the hermetic suites (contributing.md) can't: confirming that the generated commands and detection probes behave on a live machine. It uses two local VMs, Alpine (apk and OpenRC) and Debian (apt and systemd), driven over SSH from a glibc dev zap, by hand or through the differential harness. They are VMs, not containers, because service, firewall, swap, and user need their own init, netfilter, and kernel, and the wetruns below are destructive.

Prerequisites

A hypervisor with KVM (/dev/kvm present). The commands below use libvirt and virt-install, which gives the VM a host-reachable 192.168.122.x address so the throwaway config works unmodified:

sudo apt install -y virtinst libvirt-daemon-system qemu-system-x86 qemu-utils sudo systemctl enable --now libvirtd sudo virsh net-start default; sudo virsh net-autostart default # NAT network up sudo adduser "$USER" libvirt # reach qemu:///system without a polkit password prompt

Membership in libvirt is what lets the unprivileged virsh -c qemu:///system commands below, and the differential harness, run without a password. Debian's 60-libvirt.rules polkit rule grants org.libvirt.unix.manage to that group. It is effectively root, since a member can define a VM that attaches host disks. Grant it only on a dev machine where that's acceptable.

The Alpine VM

A small mixed config (an apk package, an OpenRC service, and a file) driven through detect, a dry apply, apply --wetrun, and apply again.

1. Bring up the VM

Download the latest "virt" ISO into the libvirt image pool. The system libvirt runs QEMU as libvirt-qemu, which can't read a 0750 home directory, so the ISO must not live under ~:

base=https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64 iso=$(curl -s $base/ | grep -o 'alpine-virt-[0-9.]*-x86_64.iso' | sort -u | head -1) sudo curl -fL -o /var/lib/libvirt/images/alpine-virt.iso "$base/$iso"

Create and install. This drops you into the serial console:

sudo virt-install --name alpine-zap --memory 1024 --vcpus 2 \ --disk size=4,format=qcow2 \ --cdrom /var/lib/libvirt/images/alpine-virt.iso \ --osinfo generic --network network=default \ --graphics none --console pty,target_type=serial

At localhost login: log in as root (no password) and run setup-alpine. The answers that matter:

PromptAnswerWhy
NTP clientnonekeeps chrony uninstalled, so the package step is a real test
APK mirrorfthe fastest mirror
useropsyou'll add it to wheel next
SSH serveropenssh
disksda, mode sysa full install to disk. The disk enumerates as sda, not vda, because --osinfo generic uses the SATA bus. That's fine.

Then reboot. It should come up as alpine-zap login:. From now on it's sudo virsh start alpine-zap and sudo virsh console alpine-zap (Ctrl-] exits the console). Get its address with sudo virsh domifaddr alpine-zap.

setup-alpine configures the interface for DHCP, so the address is whatever libvirt's default network leases it. The differential harness expects it at 192.168.122.10. Pin it with a DHCP host reservation for the VM's MAC, so a lease expiry or a rebuilt network can't move it. <mac> is the MAC column of virsh -c qemu:///system domiflist alpine-zap:

virsh -c qemu:///system net-update default add ip-dhcp-host \ "<host mac='<mac>' name='alpine-zap' ip='192.168.122.10'/>" --live --config

2. Make it zap-drivable

Zap shells out to sudo (not doas) and runs it non-interactively over SSH, so the target needs sudo installed and a NOPASSWD sudoer. On Alpine, sudo lives in the community repo, and so does chrony:

# in the VM, as root: setup-apkrepos -c -f # enable the community repo + fastest mirror apk update && apk add sudo adduser ops wheel echo '%wheel ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/wheel chmod 0440 /etc/sudoers.d/wheel

Install your host key so Zap connects without a prompt:

# on the host: ssh-copy-id ops@<vm-ip> ssh ops@<vm-ip> true # must return silently

3. Run Zap against it

Save this throwaway config as zap.ts in a directory of its own, beside a package.json such as {"name": "zap-alpine-probe", "type": "module"}: a config is evaluated in its project, which a package.json marks. It needs no recipes. host and user are references, resolved from the operator env at connect time:

// zap.ts export default (zap) => { zap.target({ host: zap.env('ALPINE_HOST'), user: zap.env('ALPINE_USER'), package_manager: "apk", service_manager: "openrc", }); zap.pkg({ id: "chrony", name: "chrony", from: { apk: "chrony" } }); zap.service({ id: "chronyd", name: "chronyd", service: "chronyd", enabled: true, running: true, depends_on: ["chrony"], }); zap.file({ id: "zap-probe-file", name: "zap probe file", path: "/etc/zap-alpine-probe", content: { type: "inline", content: "zap alpine validation\n" }, mode: "644", }); };

Drive it, with ZAP_FGRO set to your fgro copy (install.md). The env prefix works in every shell; in bash or zsh you can also write the assignments inline, as ALPINE_HOST=… ALPINE_USER=… zap …:

env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap lock # pin the fgro build ZAP_FGRO names env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap detect # probe sanity env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply # dry-run: see the plan env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply --wetrun # converge env ALPINE_HOST=<vm-ip> ALPINE_USER=ops zap apply # idempotency re-check

Expected:

  • detect shows chrony absent and chronyd drifted (running=false, enabled=false);
  • the dry run plans a cache-update-apk change (apk update) ahead of apk add chrony, rc-update add chronyd default and rc-service chronyd start (inside the sh -c that resets a crashed service first), and the file;
  • the wetrun reports applied: 4 ok, 0 failed, 0 skipped (of 4);
  • the final run reports (no changes — converged): the probes (apk info -e, rc-service <svc> status, the service's entry under /etc/runlevels) read the post-apply state correctly, as the wetrun's own re-read already checked (a RESIDUAL banner names anything that doesn't read converged).

The Debian VM

debian-zap is the apt, systemd, and ufw twin of alpine-zap: Debian 13's generic cloud image, configured on first boot by cloud-init from a NoCloud seed, at the static address 192.168.122.11. There is no installer to click through. The image boots straight into a configured system.

1. Fetch and verify the cloud image

base=https://cloud.debian.org/images/cloud/trixie/latest curl -fLO $base/debian-13-genericcloud-amd64.qcow2 curl -fLO $base/SHA512SUMS sha512sum --ignore-missing -c SHA512SUMS # must print "debian-13-genericcloud-amd64.qcow2: OK" qemu-img resize debian-13-genericcloud-amd64.qcow2 8G # cloud-init grows / on first boot

2. Write the cloud-init seed

Three files. user-data creates ops with NOPASSWD sudo and your public key. Zap runs sudo non-interactively, the same requirement as on Alpine:

#cloud-config hostname: debian-zap users: - name: ops groups: [sudo] shell: /bin/bash sudo: "ALL=(ALL) NOPASSWD:ALL" lock_passwd: true ssh_authorized_keys: - ssh-ed25519 AAAA… you@host ssh_pwauth: false package_update: false

meta-data names the instance:

instance-id: debian-zap local-hostname: debian-zap

network-config pins the address. A static IP keeps the configs and the harness unmodified across rebuilds:

version: 2 ethernets: primary: match: name: "en*" addresses: [192.168.122.11/24] routes: - to: default via: 192.168.122.1 nameservers: addresses: [192.168.122.1]

Pack them into an ISO labeled cidata. The label is how cloud-init finds a NoCloud seed:

xorriso -as mkisofs -output debian-zap-seed.iso -volid cidata -joliet -rock \ user-data meta-data network-config

3. Upload both into the default pool

Upload through libvirt; don't copy into /var/lib/libvirt/images/. The daemon writes the files with the ownership QEMU needs, so it works without root, and nothing lives under ~ where libvirt-qemu can't read it (the Alpine ISO gotcha):

v="virsh -c qemu:///system" $v vol-create-as default debian-zap.qcow2 8G --format qcow2 $v vol-upload --pool default debian-zap.qcow2 debian-13-genericcloud-amd64.qcow2 $v vol-create-as default debian-zap-seed.iso $(stat -c %s debian-zap-seed.iso) --format raw $v vol-upload --pool default debian-zap-seed.iso debian-zap-seed.iso

4. Create the domain

virt-install --connect qemu:///system --name debian-zap --memory 1024 --vcpus 2 \ --disk vol=default/debian-zap.qcow2,bus=virtio \ --disk vol=default/debian-zap-seed.iso,device=cdrom \ --import --osinfo debian13 --network network=default \ --graphics none --video vga --console pty,target_type=serial --noautoconsole

--video vga is required. With --graphics none alone the domain gets no video device, and this image's GRUB reboot-loops without one, never reaching the kernel. A VGA device with no graphics backend attached is invisible but satisfies GRUB. The serial console still works (virsh console debian-zap).

First boot runs cloud-init. Then ssh ops@192.168.122.11 true must return silently. The address is static in the guest, but reserve it on the libvirt side too, so the DHCP pool never leases it to another domain. <mac> comes from virsh -c qemu:///system domiflist debian-zap:

virsh -c qemu:///system net-update default add ip-dhcp-host \ "<host mac='<mac>' name='debian-zap' ip='192.168.122.11'/>" --live --config

The image ships without ufw. A config that uses the ufw firewall backend installs it first, with zap.pkg.

5. Snapshot both VMs

The differential harness starts every base build from a snapshot named pristine. It never modifies or deletes it:

virsh -c qemu:///system snapshot-create-as debian-zap pristine virsh -c qemu:///system snapshot-create-as alpine-zap pristine

Gotchas

virt-install: Could not open '…/alpine-virt.iso': Permission denied. The ISO is under ~, which libvirt-qemu can't read. Keep it in /var/lib/libvirt/images/.

Every virsh -c qemu:///system call prompts for a password (System policy prevents management of local virtualized systems). Your user isn't in the libvirt group, so polkit falls back to admin auth. Run sudo adduser "$USER" libvirt. polkit looks up group membership itself, so it usually applies without logging in again; log out and back in if it still prompts.

Guest udhcpc: no lease or Network unreachable. A restrictive host firewall (nftables input and forward default-drop) drops DHCP and forwarding on virbr0. libvirt's own accepts live in a separate table and can't override a sibling base-chain drop. Add iifname "virbr0" accept to your input and forward chains, for example sudo nft insert rule inet filter input iifname "virbr0" accept. That is runtime-only, so make it durable in your firewall config.

Guest udhcpc: sendto: Network is down. The interface is down. Run ip link set eth0 up before udhcpc. This is only relevant when testing before setup-alpine.

Console blank after virsh console. The login prompt already printed; press Enter. If virsh start boots a blank disk, it hangs on the unseen VGA console: boot from the install media or check virsh domblklist. Don't recycle the libvirt network while the VM runs, because it detaches the tap and virbr0 goes NO-CARRIER.

The Debian domain reboot-loops in GRUB right after virt-install. The domain has no video device. Recreate it with --video vga, keeping --graphics none. See the Debian VM section.

apt-get update fails after a snapshot revert with Signature … was created after the --not-after date. The guest resumed with the clock it had when the snapshot was taken. Set it from the host with ssh ops@<vm-ip> "sudo date -u -s @$(date +%s)". The differential harness does this after every revert.

The Alpine VM comes up on a different address after a rebuild or a long shutdown. setup-alpine uses DHCP. Add the host reservation from the Alpine section.

The first connection after a snapshot revert takes a few seconds. The resumed guest drops packets briefly; it is not ssh. Poll with a short ping before timing anything.

Staging a remote file or build archive relies on a target-side mktemp -d '/tmp/zap-…-XXXXXX'. BusyBox mktemp supports both -d and a template ending in XXXXXX, so this works on stock Alpine. Confirm it on the VM when changing an Alpine tar extract or build path.

The differential harness #

cargo xtask differential runs two zap binaries against identical states of the two VMs in testing.md and fails if they report anything differently. One is a trusted oracle, by default a release build of origin/main. The other is a candidate, by default the working tree.

It proves behavioral equivalence, not correctness: a refactor that passes it changed nothing an operator can observe, across real apt, systemd, and ufw hosts and real apk, OpenRC, and nftables hosts, over SSH and (on Debian) through the local connection. What it establishes about the candidate alone, whatever the oracle says:

  • with --base-by candidate, that the candidate converges the whole kitchen sink from pristine through its own review gate, in the fixture's rounds, and that a further apply then plans nothing but a pipeline;
  • that it puts back the drifted state of every scenario that reconverges, and that a check which never goes through Zap then reads the machine as it did on the base;
  • that it can replace a host's bootstrap user in place, and plan as the user that replaced it (Replacing the bootstrap user);
  • that its plans of one target state agree with each other and name the machine they are for;
  • that nothing it prints holds a raw control character.

The mechanics are in the module docs, starting at crates/xtask/src/differential/mod.rs; cargo xtask differential --help summarizes them.

What it runs

1. Base. From pristine, one of the two binaries (--base-by, the oracle by default) converges a kitchen-sink config, crates/xtask/fixtures/differential/<distro>/zap.ts, the way an operator does: a dry apply, then apply --wetrun bound to that preview, in the rounds the fixture declares (a fixture may declare resources held from pristine, such as Alpine's database before its service starts). Whichever side builds is held to that declaration, so an oracle that reads pristine differently can't build the base; build with --base-by candidate. A last dry apply must then plan nothing but a pipeline. The config holds every resource kind that converges on that distro, with edge cases: paths with spaces and quotes, content with no final newline or with CRLF endings, script checks that read stdin, run slowly, or carry a reference, tracked and vendored pins, a triggered service, a firewall that allows 22/tcp (the harness's own connection is the lockout test), and privilege canaries that read ok only when a probe runs in its expected lane.

The result is snapshotted as differential-base and reused until a hash of its inputs changes (the pristine snapshot, the fixtures, the src/lib modules, the distro's preparation, the test CA, the builder, and the fgro build). --rebase forces a rebuild.

2. Scenarios. For each scenario, the harness reverts to the base and puts the target in the scenario's state: a root shell mutation (a same-size edit, a chmod, a removed package, a stopped service, swapoff, a flipped firewall policy, a dropped pg_hba line, a pending-trigger marker), an operator-side change (a zap.lock entry dropped or made stale, an env var changed or unset), or both. Then it runs both binaries, each with its own fresh ZAP_HOME and its own staged copy of the fixtures, over detect --no-cache, plan --no-cache --diff, and plan --no-cache. Scenarios with remote files run twice, with a cold and a warm content cache. One scenario, bootstrap-replace, is of its own kind: it compares nothing (Replacing the bootstrap user).

3. Reconverge. A scenario that drifts declared state Zap can put back in one offline round carries a probe: a root shell snippet that never goes through Zap and prints the state the scenario touches (the firewall's policy and rules, as the kernel holds them and as their files say, a user's password field and authorized_keys, the database's pg_hba rules), read where it is in force rather than only where detection reads it. The probe reads the base before the mutation and the target right after it, where it must read differently, or the drift didn't take. Once the read-only runs are compared and audited, the candidate alone, with its own staged tree and a fresh ZAP_HOME, applies the drifted state back the way an operator does: a dry apply that must plan exactly the scenario's expected resources and nothing else but a pipeline, then apply --wetrun bound to it, which must complete every change and run the reviewed plan_hash. A further dry apply must then plan nothing but a pipeline, detect must read every resource but the pipeline ok (a pipeline reads absent by design), and the probe must read what it read on the base. The probe is the check that isn't circular: a detector that misreads wrong state as converged passes Zap's own checks and fails it. The candidate alone applies because the read-only phase has already compared its plan of that state with the oracle's; executing it is the candidate's own property.

4. Remote content is generated deterministically and served from inside xtask: plain HTTP on loopback for the Zap runs on your machine, and an https origin on the libvirt gateway address for everything a guest fetches, under a test CA the harness makes once with openssl and keeps in ~/.cache/zap-differential/pki. The CA is name-constrained to the names the origin serves, and only the differential-base snapshots trust it, never your machine or pristine. There is no external fetch.

5. Locks. Each binary mints its own zap.lock with zap lock, and the two are compared: a different version, toolchain pin, or entry pin is an ERROR; the same pins with a different config_hash are a divergence under --compare exact and only reported under --compare semantic.

6. Local suite (Debian only). Both binaries, and the fgro build, are copied to the VM and run there as ops, with --fgro, against a local: true fixture (debian-local/zap.ts), covering the local connection's native reads and its sudo fallback.

Each binary's fixtures, and the local suite's bundle on the VM, are staged as a project of their own: a package.json at the root and the src/lib modules copied in, so the configs' project, and the box they evaluate in, is that tree. A run's scratch tree is a directory under ~/.cache/zap-differential/runs, named for its process. It is removed when the run ends, and a killed run's is removed by the next run.

Each VM is left at differential-base afterwards, or at pristine if no base could be built. A run writes nothing into the repo.

What makes a scenario count

Two binaries that fail the same way would match, so a compared scenario is an ERROR, and the run fails, unless:

  • the oracle's output is a well-formed report: exit 0 or 1, a detect that reports every fixture resource, and a plan_hash: line on each plan;
  • no run timed out;
  • the oracle's detect moved exactly the resources the scenario expects, with their marks (some expectations pin a known oracle defect; the scenario table labels each);
  • the runs were read-only and network-free: neither listener saw a connection, no file changed under the audited roots (/etc, /opt, /home, /root, /srv, /usr, /lib, /var/lib, any regular file directly under /, and / itself), and the runtime state no file shows (the firewall ruleset with handles, running services with their identity, /proc/swaps, the postgres roles and databases) reads the same. Only proven churn is excluded, and the audit must show its own anchors (the fixture's sleeper service, the postgres role, an active firewall), so a probe failing the same way before and after can't pass blind;
  • the candidate's own output is consistent: its plan and plan --diff of one state (and its cold and warm runs) print one and the same plan_hash; each plan names its machine directly under the header (over SSH, host: and user: lines, then target:; on the local suite, user: and home: lines, then target:); and no line of its output holds a control character;
  • in a scenario that reconverges, every check of the reconverge phase holds, the apply fetched no remote content, and it left the candidate's zap.lock as zap lock minted it. A failure here is tagged [reconverge], so it reads apart from a failure of the read-only phase.

Only an SSH scenario reconverges, and only one whose expected marks are all drift or absent (a held ? stops the preview at exit 1), with no operator-side change and no remote content: the apply fetches nothing.

Replacing the bootstrap user

bootstrap-replace rehearses what an operator does once a host is up: replace the stock account it was brought up with (The bootstrap user) by a user of its own, then remove the stock account. It runs on both distros, in the default matrix, with the candidate alone and its own small configs, crates/xtask/fixtures/bootstrap/, kept apart from the kitchen sinks so the base doesn't depend on them. From the base:

  1. Provision. zapnext doesn't exist yet. A config whose target connects as zapnext, with ops as its bootstrap_user, declares that user with ssh_keys: {copy_from: 'ops'}, sudo: 'nopasswd', and /bin/sh. It is locked, previewed with apply --bootstrap, which must plan the user's creation and nothing else and name ops as its user, applied with apply --bootstrap --wetrun bound to that preview, and previewed again, which must plan nothing.
  2. Prove. Over its own login, zapnext is who it says, sudo -n true works, and the grant is Zap's: /etc/sudoers.d/zapnext holds Zap's line, root's at 440, and zapnext is in no sudo, wheel, or admin group. Its authorized_keys is ops's, byte for byte.
  3. Retire. As root through zapnext's login (a session of ops can't remove ops), ops's sessions are ended and its processes killed, a bounded wait sees none left, and userdel -r ops runs without -f, so a process that outlives the kill fails it. On Debian, cloud-init's /etc/sudoers.d/90-cloud-init-users, which grants ops by name, must grant no one else, and is removed.
  4. After. As zapnext: ops has no account, no home, no sudoers line, and no group, visudo -c passes, and zapnext's keys are unchanged. A login as ops is refused, and a login as zapnext right after succeeds, so a VM that went down can't read as the refusal.
  5. Plan as zapnext. A second config declares the same user with the same attributes, its keys as the list it holds (a module the harness writes from zapnext's authorized_keys, never checked in). Its plan, without --bootstrap, reads (no changes — converged) and names zapnext as its user. The first config, its copy_from source gone, plans the user again, and its detect gives the reason, ssh keys unreadable: a missing source reads as drift to apply, never as converged.
PASS ssh/bootstrap-replace 11.1s (revert 7.5s, provision 1.3s, prove 0.8s, retire 0.5s, after 1.0s) the bootstrap user replaced in place: zapnext provisioned with --bootstrap, then ops retired over zapnext's own login zapnext logs in, its sudo is zap's grant alone, and it holds ops's keys ops retired: no account, home, sudoers line, or group; its login is refused as zapnext: the retired config reads converged; the provision config's copy_from source reads unreadable, not converged

It proves that sequence on these VMs, not every host's: a host whose sshd admits only some users (AllowUsers), or whose stock account holds sudo some other way, needs its own steps. It doesn't compare the two binaries or run the read-only audit: it writes on purpose, and the audit reaches the VM as ops. Its own checks stand in: root's reading of the accounts and sudoers after the retire, and the run fetching nothing. Every step after the retire reaches the VM as zapnext. The revert that starts the next scenario, or the run's last one, brings ops back.

Under --keep it stops after step 2, leaving ops in place: a VM left without ops stops the next run before any revert, since the harness waits for the VM as ops. A run killed after the retire leaves it that way; revert the VM by hand:

virsh -c qemu:///system snapshot-revert <domain> differential-base

Prerequisites

  • Both VMs from testing.md, each with a pristine snapshot and your key in ops's authorized_keys.
  • ZAP_FGRO, the absolute path of the fgro build both binaries evaluate the fixtures with. Each run is given it as --fgro; each staged config, and the local bundle's, starts beside a zap.lock pinning it, generated from that binary rather than checked in, since an fgro build names one machine's bytes; and the local bundle carries a copy to the Debian VM, whose kernel must hold fgro's sandbox (Landlock ABI 6 or later).
  • GNU tar and gzip, and ping.
  • openssl on PATH, the first time, to make the test CA.
  • The libvirt default network active, and guests able to reach a TCP port on its gateway address (the firewall note applies to this too).
  • An oracle that evaluates through fgro (it takes --fgro), and keeps its SSH control socket under its ZAP_HOME, as the working tree does. The harness checks both before it starts a VM: a zap build that predates fgro can't read the staged locks.
  • No root: your user in the libvirt group (Prerequisites).

Running it

cargo xtask differential # origin/main vs the working tree, both VMs cargo xtask differential --distro alpine --scenario firewall-policy cargo xtask differential --oracle-rev <rev> # the oracle built from another revision cargo xtask differential --oracle ./zap-reference # a binary you built cargo xtask differential --candidate ./zap-under-test --keep cargo xtask differential --base-by candidate # the candidate builds the base cargo xtask differential --compare semantic # compare decisions, not rendering cargo xtask differential --help # every flag
FlagDefaultMeaning
--oracle <path>a build of --oracle-revthe reference binary
--oracle-rev <rev>origin/mainthe committed revision the oracle is built from; not with --oracle
--candidate <path>a release build of the working treethe binary under test
--distro <name>bothalpine or debian; repeatable
--scenario <name>allan exact scenario name; repeatable. An unknown name lists the valid ones.
--compare <level>exactexact or semantic; see compare levels
--base-by <side>oracleoracle or candidate: which binary builds the base
--rebaseoffrebuild the base even when its hash matches
--keepoffskip the final revert and every reconverge phase, so you can inspect the VM in the last scenario's state (drifted, for a scenario that reconverges), or, after a failed base build, in the state the builder left it; bootstrap-replace stops before it retires ops

Without --oracle, the oracle is ~/.zap/oracle/zap-<short-rev> for the commit the revision names, as the local ref stands (nothing is fetched). It is reused while that file is there; delete it to force a rebuild. Otherwise the harness builds it the way you would by hand: the revision's tree from git archive in a temporary directory under ~/.cache/zap-differential, beside a link to each sibling checkout its Cargo.toml names by relative path, then cargo build --release -p zap there under the revision's own rust-toolchain.toml (CARGO_BUILD_JOBS applies), and the executable copied into place. A sibling such as ../fuz is linked as it stands now, uncommitted changes included, not as it was at the revision. The temporary directory is removed afterwards; one an interrupted build left behind is removed by the next build. The run prints which oracle it used:

oracle rev origin/main = 0123abc (reused /home/ops/.zap/oracle/zap-0123abc)

The default is origin/main rather than the checked-out branch's upstream: the oracle is what has landed, whatever branch the working tree is on.

Exit statusMeaning
0every scenario matched
1any divergence or harness error
2a usage error

The run prints both binaries' blake3 and warns when they are byte-identical: such a run checks the harness and the targets, but proves nothing about a change.

Reading the output

Each scenario prints PASS, DIVERGED, or ERROR, its phase times, what its mutation does, and the resources the oracle's detect saw leave ok; a scenario that reconverges also times that phase and says when the candidate put the state back:

PASS ssh/firewall-policy 25.1s (revert 5.0s, zap runs 6.0s, audit 0.3s, reconverge 12.4s) default incoming policy flipped to allow oracle detect moved: firewall(drift) candidate reconverged it: the probe reads the base again

A reconverge failure is an ERROR whose message starts [reconverge] and names the check that failed; a probe that doesn't read the base again prints the difference, - for the base and + for after the apply. A probe reads what the declaration governs, no more: the pg_hba probe reads which of the database's rules are present, not where, since Zap puts a missing rule ahead of the first host line rather than back where it stood.

A divergence prints, per command and stream, a unified diff with - for the oracle and + for the candidate, tagged [cold], [warm], or [local]:

DIVERGED ssh/converged 14.2s (revert 6.5s, zap runs 7.7s, audit 0.3s) oracle detect moved: nothing [cold] detect: stdout: --- oracle +++ candidate … ok sleeper-init file — converged - ok sleeper service — converged + drift sleeper service — drifted (running=false, enabled=true) ok postgresql package — converged …

Only what differs between the runs by construction is normalized: each run's ZAP_HOME path (<ZAP_HOME>), the binary's own path (<ZAP>), and its staged fixture tree (<STAGE>). Everything else, exit codes and stderr included, is compared verbatim, so every diff line is a behavior change: either the intended one, which you record, or a regression.

An ERROR is not a verdict; the message says which check above failed, or that the VM was unreachable, the base didn't converge, the two locks pinned different things, or an intended-divergence entry went stale.

Compare levels

--compare exact, the default, compares byte for byte. --compare semantic is for an oracle that renders a plan differently by design: it projects both sides down to what each command decided (verdicts, banners, held resources, the set and order of changes with their action, triggers, --diff attribute lines, warnings, exit codes) and leaves out each plan's machine and references: block, the lines under a change, and the value of plan_hash. So a semantic PASS doesn't establish that the two binaries would run the same commands or compute the same hash. The projection is compare::project_lines.

A change to what a plan's steps run, such as an extraction script, moves the plan_hash of every plan that runs it, and that hash also binds the plan's references, among them the per-run port of the harness's loopback content server, so no entry can record it. Against an oracle that predates such a change, an exact run diverges on those lines by design: compare at the semantic level, where the intended entries still apply. The exact level passes again once the oracle is a build that carries the change.

Intended divergences

A candidate that changes behavior on purpose diverges by construction. Each such change is an entry in crates/xtask/src/differential/intended.rs: the distro, suite, scenario, cold or warm variant, command, the exact oracle and candidate lines, the resource, and the reason. Matching is whole lines, exact bytes, and only for the run the entry names; an accepted line prints as an INTENDED block and doesn't fail the scenario:

PASS ssh/file-edge-cases 19.9s (revert 4.8s, zap runs 13.4s, audit 0.4s) … INTENDED [cold] detect Stdout source-copy: the unreadable-source hold names the path, not the old `cat` stderr - ? source-copy file — undetermined: failed to render content: failed to read file … + ? source-copy file — undetermined: failed to read the content source: …

An entry whose lines aren't among its run's changed lines is an ERROR, so when the oracle is replaced by a build that already behaves the new way, its entries fail and the table empties itself out. The table is reviewed like code.

api #

Browse the full api docs.