The ledger: declared state vs deployed state¶
The ledger is the declared record of the fleet, held as YAML shards in the fleet repo.
Writing it is not deploying it: agentctl agents set … records the intent, agentctl apply
renders it onto the host.
| Path | Holds |
|---|---|
agents.yaml |
the host block and the host keys |
agents.d/<name>.yaml |
one shard per agent |
secrets.d/<owner>.yaml |
one shard per secret owner |
Shards are disjoint files, so git auto-merges concurrent writes and many hosts can drive one
repo. For every layered category a shard carries only activate: / deactivate: against the
defaults, never the resolved set, so a newly-shipped default self-propagates. See
Architecture.
Reading the difference¶
There is no diff verb and no standing drift report. A dry-run reconcile produces the diff:
every reconciler renders the artifact it declares, compares it to disk, and returns a
Changeset of files, symlinks, directories and units.
| Verb | Scope | Resolution | Pulls the repo first |
|---|---|---|---|
agentctl converge <agent> |
one agent's manifest | artifact-level: file, symlink, unit | no |
agentctl provision --check |
the host base: groups, slices, host units and drop-ins, managed directories | artifact-level: file, symlink, unit | no |
agentctl apply --dry-run |
the whole fleet | plan-level: one row per agent | no |
agentctl apply |
the whole fleet | writes | yes |
Host-base artifacts belong to no agent's manifest, so converge <agent> never shows them.
converge defaults to --dry-run; --apply writes. It prints 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
apply --dry-run returns before any reconciler executes. 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 restart set the run would produce. It shows no file-,
symlink- or unit-level change, for any agent or for the host base. The host crons row
reflects the cron delta only.
Because a dry-run reads the local checkout, on a multi-host fleet it can come back clean against a checkout that is itself behind origin.
Verbs that answer a neighbouring question¶
| Verb | Reports | Reads the desired state |
|---|---|---|
agentctl doctor [<name>], agentctl health |
whether the deployed thing works — unit state, crash loops, dead logins, an MCP missing its secret | no |
agentctl status |
host readiness | no |
A host passes all of them while holding files the ledger no longer describes.
What the reconcile compares¶
Each reconciler compares against reality, not against a record of its own past writes. A hand-edit on the host is detected the same way a ledger edit is.
| Dimension | Compared | On a difference |
|---|---|---|
| File content | rendered bytes vs the bytes on disk | rewrite |
| File mode | declared mode vs actual permissions | rewrite; mode drift alone counts as a change |
| Symlinks | actual target vs declared target | rewrite a missing or wrong link |
| Orphaned symlinks | catalog-owned links the agent's effective list no longer names | prune |
| Real files, links pointing outside the catalog | — | left alone |
| Declared-absent paths | the manifest's removals | delete if present |
| Unit enable-state | the multi-user.target.wants symlink vs the declaration |
enable --now / disable --now |
| Unit active-state | running vs the declaration | converged independently of enable-state |
Latched failed state |
units whose files this run rewrote | reset-failed |
Two scoping rules:
- A removal under
/etc/systemd/systemgates the daemon-reload exactly as a write does. - The
reset-failedclear is keyed on the changed files, not on the declared unit list. A unit's.servicemay be a managed plain file while only its.timeris a managed unit.
Where drift comes from¶
| Source | Mechanism | What clears it |
|---|---|---|
| Someone changed the host | a systemctl disable, an edited unit file, a deleted rendered file |
the next reconcile of that agent |
| The package moved, nothing applied it | apt-daily-upgrade.timer upgrades the deb; only apply lands host units and re-stages agent code |
an operator apply, or a fleet apply dispatched to every host — nothing converges a host on its own |
| The host base is version-gated | within one version, a plain apply does not rebuild the host manifest |
a version change, or agentctl provision |
A shared @ template changed |
apply restart-gates instances on the host-role files it wrote earlier in the same run |
apply --force |
| The fingerprint skip | apply skips an agent whose fingerprint matches the stamp from its last converge |
an input moves, a structural check fails, or --force |
The operator maintenance shell and the per-agent cron/unit templates are exceptions to the
version gate: they reconcile on every full-fleet apply.
The template restart-gate is an event, not a state. It records that the template changed during this apply, not that a unit is older than its fragment. An apply that dies between the host write and the agent phase therefore leaves a stale process no later apply corrects.
The fingerprint¶
| Signal | Role |
|---|---|
| the agentctl version | hashed input |
| a content hash of the repo overlay | hashed input |
| the agent's ledger shard | hashed input |
| a digest of the agent's secrets | hashed input |
| the agent's home directory | structural check |
| the rendered env file | structural check |
| a pi agent's permission-floor extension | structural check |
| the agent's own unit still enabled | structural check |
The fingerprint hashes declared inputs and nothing about the deployed result. A failing
structural check forces the reconcile regardless of the fingerprint. Drift that no input
reflects and no check covers stays skipped until an input moves, or until --force.
How apply reconciles it¶
| # | Stage | Runs |
|---|---|---|
| 1 | host base | only when a version change triggered a re-provision |
| 2 | host-global unit and cron templates | every full-fleet apply |
| 3 | per-agent plays | per agent, unless the fingerprint skip applies |
| 4 | restart tail | on the gated restart set |
Each stage renders desired state and writes only on a diff, which makes a second apply on
an unchanged host a true no-op: no writes, no daemon-reload, no restarts. Restarts are gated,
not blanket.
| Trigger | Restarts |
|---|---|
| a unit's declared inputs changed this run | that unit |
| an agent's fingerprint moved | that agent |
apply --force |
every agent and gateway; fingerprint skip disabled, full converge of each |
The manifest is the boundary. apply converges what the manifest declares and what its
removals name. A file that no manifest describes and no removal lists is untracked rather
than drift, and no verb here reports or deletes it.