Skip to content

Trusting desired state

Two questions have to be answered before an agent acts on a revision. Was this desired state produced by someone trusted, and is it the current one. Transport authentication answers neither. A commit pushed from a compromised account on the Git server arrives over a valid connection.

Implementation status

All three trust.require modes work, including signed-tag candidate selection and its refusal of an ambiguous pair, and the descendant check refuses both a downgrade and a history whose recorded revision has gone. Verification is git's own, against trust.signers, so a well-formed signature made by an untrusted key is refused.

The refusal of a resource that targets a trust anchor is enforced, in two places. A manifest is refused by name when its graph is built, which needs no host, and a path that reaches protected state through a hard link or through a link already on the disk is refused immediately before anything is written, because that route is only visible where the host is.

trust.strictPaths is read and does nothing.

Agent configuration

Everything on this page is configured locally, outside the repository, for the reason given in trust anchors. The keys below are the ones this page explains, and the agent configuration reference is the complete file.

/etc/datum/agent.yaml
host: web-001

source:
  url: https://git.example.com/fleet.git
  branch: main
  credential: /etc/datum/credentials/git

trust:
  signers: /etc/datum/allowed-signers
  require: signed-tag
  tagPattern: "release-*"
  requireDescendant: true
  strictPaths: false

state: /var/lib/datum

Verifying that a revision is genuine

trust.require selects what has to carry a valid signature from a key in trust.signers.

Value The agent applies
signed-commit The branch tip, and only if that commit is signed by a trusted key.
signed-tag The revision named by a signed tag matching tagPattern, not the branch tip.
none The branch tip, unverified.

Both signing modes are offered because requiring a signature on every commit has an operational cost. It constrains automation that generates commits, and it makes key rotation a fleet-wide event. signed-tag moves the requirement to a release step, so day-to-day commits need no signature and the fleet acts on a signed statement that a revision is fit to deploy.

none lets a fleet run before signing is set up. An agent configured with none records that in every report, so an unverified fleet is visible.

Choosing among signed tags

With signed-tag there can be several candidates, and picking the wrong one is a downgrade.

A candidate is a tag meeting all four of the following, and anything else is ignored and not treated as an error.

Requirement Reason
An annotated tag object A lightweight tag is a bare reference with nothing to sign, so it cannot carry a signature.
Signed by a key in trust.signers The signature is checked on the tag object itself, not on the commit it points at.
Name matches tagPattern The pattern is what separates release tags from every other tag in the repository.
Reachable from source.branch A tag on a branch nobody deploys is not a deployment candidate.
A descendant of the recorded revision This bounds the search, and a tag older than the baseline could never be applied anyway.

From that set the agent selects the unique candidate that is a descendant of every other candidate. Two tags pointing at the same commit count as one candidate, because neither is a strict descendant of the other and refusing that case would make a repository unable to tag a release twice.

If no unique candidate exists, because two signed tags sit on genuinely diverged histories, the agent refuses and reports both instead of choosing.

error: ambiguous signed tag
  release-2026.02.03   a41c9d3   signed by D065FC86
  release-2026.02.04   7b2e918   signed by D065FC86
  neither descends from the other

  the host remains on 8b91f20

Bounding candidates by the recorded revision keeps selection cheap on a long-lived repository. Considering every signed tag in history would make the cost of selection grow with the age of the fleet, and an old release tag that is never deleted would stay in the candidate set.

Selecting by tag name or by date was rejected. Name ordering depends on the collation applied, and dates in a tag are metadata the tagger sets. Ancestry is a property of the history.

Every one of these checks depends on the local clone holding complete history and all tags, which is why obtaining the repository specifies that it does.

Verifying that a revision is current

A signed old revision is still validly signed, so signature verification does not stop a downgrade. trust.requireDescendant closes that.

The agent records the newest revision it has accepted in its state directory. On the next pass, the candidate revision has to be a descendant of that recorded revision, which Git can answer directly, and anything else is refused.

Accepted means the revision carried a valid signature, satisfied the descendant requirement, resolved into an effective manifest and passed manifest validation. A pass that failed to apply still counts as accepted, so the pointer advances. Holding the pointer back would pin a host with one persistently failing resource at an old revision, and the commit that fixes that resource could not reach it.

signature valid, descendant, resolved, validated   pointer advances
apply failed, verification failed                  pointer still advances
signature invalid, not a descendant, unresolvable  pointer unchanged

One value is stored and Git supplies the ordering, so the same pointer serves both this control and last known good. A revert is expressed as a new commit, and the branch is not rewritten.

The cases this makes awkward

A repository that force-pushes, or a host that has been off long enough for its recorded revision to have been removed from history, will fail the descendant check and keep failing it.

Recovery is a one-shot operation. An operator clears the recorded revision with a documented command on that host. There is no configuration flag for it, since a flag set once during an incident tends to stay set.

First contact

A host with no recorded revision cannot detect a downgrade, because there is nothing to compare against. Whatever signed revision it first sees becomes its baseline.

This is a trust-on-first-use gap, and the host cannot close it. Provisioning writes the initial revision into the state directory when the machine is built, which gives the first pass a baseline set from outside.

Trust anchors are never managed by Datum

The agent's identity, its credentials and its signer list are not managed by Datum resources, and Datum refuses to reconcile a resource whose target is one of them.

The case this prevents is a bootstrapping attack. With allowed-signers as an ordinary File resource, one malicious commit replaces it with an attacker's key, and every subsequent commit verifies against that key.

The refusal names specific targets and does not exclude /etc as a whole. A resource targeting the agent's configuration file, its state directory, its credential files or its signer list is rejected when the manifest is validated, so it fails before the host is touched and the error names the file and the control it protects.

Path Protects
/etc/datum/agent.yaml Host identity, source, and the trust settings themselves
/etc/datum/allowed-signers The set of keys that can authorise desired state
/etc/datum/credentials/ Repository credentials
/etc/datum/secrets/ Locally held secret material
/var/lib/datum/ The recorded revision, the pass lock and pass reports
/usr/bin/datum The agent binary itself
The agent's package and service unit The process that enforces everything above

This is recorded as ADR-0010.

How a protected target is matched

The check operates on resolved target identity. Matching a resource's declared path against the list above would be defeated by each of the following.

Route What the check has to do
/etc/datum/../datum/agent.yaml Canonicalise before comparing.
A Symlink at /etc/datum/agent.yaml pointing elsewhere Refuse a link whose path is protected, whatever it targets.
A Symlink elsewhere pointing into /etc/datum/ Refuse by the resolved target, not the declared path.
A hard link into a protected file Compare device and inode, not names.
A Directory resource for /etc/datum or /etc Refuse an ancestor of a protected path.
A File at /etc/datum/secrets/app-password Refuse anything beneath a protected directory.

Paths are canonicalised and compared as device and inode where the target exists, and by containment against the protected prefixes where it does not. An ancestor of a protected path is refused as well as a descendant, since a resource managing /etc covers everything the list names.

The two comparisons happen at different points. Containment and canonicalisation are properties of the manifest, so they are checked when the graph is built and fail with no host involved, which is what datum validate reports in a pull request. Device and inode are properties of a filesystem, so they are checked on the host immediately before a resource is applied. An earlier check would leave a window in which a link placed between the check and the write would be followed.

The protected paths come from the agent's own configuration rather than from the table above. A host that keeps its state elsewhere needs that directory protected, and the default location would not be the one in use.

Routes other than the filesystem

The list above covers the filesystem. Three resource types reach the same outcome without touching any of those paths.

Package[datum]        state: absent      removes the agent
Service[datum]        state: stopped     stops it reconciling
User[root]            shell: /bin/false  breaks the host in a way nothing recovers from

A Package or Service resource whose target identity is the agent's own package or unit is refused by the same mechanism, which is why both appear in the table above as things rather than paths. The self-management boundary follows from the same refusal. A resource that targets the agent is either refused, or handled outside the running reconciler, and it is never applied inline.

Root's own account is outside the list. Datum manages users, root is a user, and a fleet has legitimate reasons to set root's shell. Covering it would extend the list to every route to root, which is most of /etc, leaving a general exclusion and placing the whole of the control on review of the diff.

Important limitation

There is no complete enumeration of paths that grant root. A File writing /etc/sudoers.d/, /etc/pam.d/, a systemd unit, a cron entry or root's authorized_keys grants root and is permitted, since each is something a configuration system manages. The list here keeps desired state from disabling Datum's own controls. It offers the host no protection against a repository that is trusted to configure it.

Three things narrow it. Privileged modes are refused, a package source has to state the grant, and the plan flags writes to paths that grant privilege so review has a signal.

The cost is that rotating a signer key or moving a fleet to a new remote falls to whatever builds machines. That cost is accepted in exchange for a control that desired state cannot disable.

Repository credentials

The credential a host uses to read Git is read-only, because an agent never writes to the repository, and it is stored at source.credential owned by root with mode 0600.

Per-host credentials are strongly preferred over one shared credential. A shared credential cannot be revoked without touching every machine, so the first compromise becomes a fleet-wide rotation. A per-host credential is revoked on its own.

Important limitation

Every host can still read the whole repository, including every other host's configuration. Per-host credentials limit who can use a stolen credential and not what it grants. Narrowing what a host can read would require resolving manifests away from the host, which Datum does not do.