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.
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.