The ledger: declared state vs deployed state¶
The ledger is the per-agent declared record: agents.yaml holds the host block and host
keys, and each agent lives in its own agents.d/<name>.yaml shard. Sharding per agent (and
per owner, for secrets.d/) makes concurrent writers commute — disjoint files, so git
auto-merges and many hosts can drive one repo.
Two properties matter more than the file format, and both are covered in Architecture:
- The shard records divergence, not the resolved set. For every layered category the
shard carries only
activate:/deactivate:against the defaults, so a newly-shipped default self-propagates instead of being frozen out by a stale list. - Writing the ledger is not deploying it.
agentctl agents set …records the intent;agentctl applyis what renders it onto the host.
Reading the difference¶
There is no diff verb and no standing drift report. The diff is produced by running a
reconcile in dry-run mode: every reconciler renders the artifact it declares, compares
it to what is on disk, and returns a Changeset — files, symlinks, directories, units.
That changeset is the declared-vs-deployed diff. Three verbs expose it, at three
different scopes and two different resolutions.
agentctl converge <agent> — the per-agent, artifact-level diff, and the only thing
that produces one. --dry-run is the default; --apply is what writes. One line per
pending change:
file ~ /etc/systemd/system/agent@noor.service.d/override.conf (a1b2c3 -> d4e5f6)
symlink - .claude/skills/old-skill
unit ↻ agent@noor.service
agentctl provision --check — the same computation for the host base (groups,
slices, host units and drop-ins, managed directories). Host-level artifacts belong to no
agent's manifest, so no converge <agent> will ever show them.
agentctl apply --dry-run — the fleet plan, which is a coarser object than the
two above. It prints one row per agent (NEW / reconcile / changes / up-to-date)
with the MCP, skill, subagent and cron deltas, a host crons row, and the exact restart
set the run would produce. It then returns, before any reconciler executes — so it shows
no file-, symlink- or unit-level change, for any agent or for the host base. The
host crons row is named narrowly on purpose: it reflects the cron delta and nothing else.
Read as a verdict on "the host", it has already misled once — a --dry-run printed a
clean host row on a machine whose agentctl-upload-reap.service was a version stale and
hard-failing every hour.
Two verbs answer a neighbouring question and should not be mistaken for this one.
agentctl doctor [<name>] and agentctl health report whether the deployed thing is
working — unit state, crash loops, dead provider logins, an activated MCP whose required
secret is absent. agentctl status reports host readiness. Neither reads the ledger's
desired state, so a host can pass both while holding files the ledger no longer describes.
One caveat on freshness: a real apply pulls the fleet repo from origin before reading it,
so it diffs against the latest committed ledger. apply --dry-run and converge skip
that pull and read the local checkout. On a multi-host fleet a dry-run can therefore be
clean against a checkout that is itself behind.
What the reconcile compares¶
Each reconciler compares against reality rather than against a record of its own past writes, so a hand-edit on the host is detected the same way a ledger edit is.
- File content and mode. Rendered bytes against the bytes on disk, and the declared mode against the file's actual permissions. Mode drift alone counts as a change.
- Symlinks. A missing link or one pointing at the wrong target is rewritten. Orphans are pruned, but only catalog-owned ones: a link in a managed directory whose target points into a catalog root and which the agent's effective list no longer names. A real file, or a symlink pointing outside the catalog, is left alone — that is what keeps a skill an agent authored in its own home safe from a reconcile.
- Declared-absent paths. The manifest's removals are deleted if present. A removal
under
/etc/systemd/systemgates the daemon-reload exactly as a write does, so the reload never re-reads the drop-in the run just decided to delete. - Unit enable-state, not only active-state. The two are independent on a live host and
checking one hides drift in the other: a unit that merely crashed is inactive with its
multi-user.target.wantssymlink intact, so it returns at the next boot; a hand-started unit is active while still disabled, so it does not. The reconcile converges both —enable --nowfor a unit declared started,disable --nowfor one declared stopped. - Latched failures. systemd keeps
failedsticky until something callsreset-failed. A unit whose file the reconcile just rewrote gets its stale failure cleared, so a fixed unit stops flying a flag from before its own fix. The clear is keyed on the changed files, not on the declared unit list, because a unit's.servicemay be managed as a plain file while only its.timeris a managed unit.
Where drift comes from¶
Someone changed the host. A systemctl disable, an edited unit file, a deleted
rendered file. This is the case the comparisons above are built for; the next reconcile of
that agent heals it.
The package moved and nothing applied it. apt-daily-upgrade.timer upgrades the deb
unattended, but apply is what lands host unit files and re-stages agent code — so a host
can sit on a new binary with old units indefinitely. apply --if-upgraded, on a timer,
exists for exactly this: it compares the version stamped at the last provision against the
running binary's and exits cheaply when they agree.
The host base only reconciles on a version change. Within one version, a plain apply
does not rebuild the host manifest. That runs on the version-gated re-provision or on an
explicit agentctl provision. A hand-edit to a host-level unit file therefore survives
every apply until the version moves or provision is run directly. (The operator
maintenance shell and the per-agent cron/unit templates are deliberate exceptions,
reconciled on every full-fleet apply — a kill switch that waits for the next package
upgrade is not a kill switch.)
A shared @ template changed but its instances did not restart. apply carries a
process-scoped record of the host-role files it changed earlier in the same run into the
per-agent pass, so instances of a rewritten template are restart-gated on it. That record
is an event, not a state: it says "the template changed during this apply", not "this
unit is older than its fragment". An apply that dies between the host write and the agent
phase leaves a stale process the next apply will not correct, because by then the file
already matches.
The fingerprint skip. A plain apply skips an agent whose fingerprint matches the
stamp from its last converge. That fingerprint hashes the agent's declared inputs — the
agentctl version, a content hash of the repo overlay, the agent's ledger shard, and a
digest of its secrets — and nothing about the deployed result. Drift that no input reflects
is invisible to it. A cheap structural check compensates for the parts that matter most:
the agent's home directory, its rendered env file, a pi agent's permission-floor extension,
and whether its own unit is still enabled. Any of those failing forces the reconcile
regardless of the fingerprint. Drift outside that short list stays skipped until an input
moves — or until --force.
How apply reconciles it¶
The order is host base first (when a version change triggered a re-provision), then the
host-global unit and cron templates, then the per-agent plays, then the restart tail. Each
stage renders desired state and writes only on a diff, which is what makes a second apply
on an unchanged host a true no-op: no writes, no daemon-reload, no restarts.
Restarts are gated, not blanket. A unit is restarted when one of its declared inputs
changed this run; an agent is restarted when its fingerprint moved. apply --force
disables the fingerprint skip, re-runs every agent's full converge, and restarts every
agent and gateway regardless — the escape hatch for drift the diff cannot see.
The boundary of all of this is the manifest. apply converges what the manifest declares
and what its removals name. A file no manifest describes and no removal lists is not
drift; it is untracked, and no verb here will report or delete it.