Skip to content

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/system gates the daemon-reload exactly as a write does.
  • The reset-failed clear is keyed on the changed files, not on the declared unit list. A unit's .service may be a managed plain file while only its .timer is 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.