Config version history and diff
Config version history is a git-style, hash-chained, signed record of your whole governance posture: the downpipes, the roles and group mappings, the custom roles, the notify channels and rules, the accepted posture risks, and the tracked credential expiry items. The engine captures a new version automatically after every config change that commits, and you can also take a snapshot on demand. Each version is signed, content-hashed and chained to the one before it, so you can list the history newest-first, read any single version, ask for a plain-English diff between two versions, and verify that the chain has not been altered.
This page is for a developer or an integrator working against the engine directly. It is a reference for the four routes the capability exposes, the integrity model behind them, and the access rules that gate them. There is also a console screen at /config/history that renders the same timeline, diff and chain-verify; the routes below are what that screen calls, and they remain directly callable if you are integrating rather than using the portal.
Three config concepts that are easy to confuse
There are three nearby config mechanisms, and they answer different questions. Keep them apart.
| Mechanism | What it is | Where it lives |
|---|---|---|
| Four-eyes change control | An opt-in gate that holds a future config change until a second authorised person approves it | An engine route with a console inbox, see change control |
| Config version history (this page) | An always-on, signed audit of the engine’s own past governance config | An engine API, plus a console screen at /config/history |
| Cloudflare config backup | Backing up your Cloudflare account configuration as a data source you can restore | A console and engine feature, see Cloudflare config backup and restore |
The version history is an audit of what the engine’s governance config was, after changes have already applied. Change control queues a change before it applies and asks a second person to approve it. Cloudflare config backup has nothing to do with the engine’s own config at all: it treats your Cloudflare account settings as customer data and protects them like any other source. The plain-English diff is the one piece of shared machinery: change control renders the same diff over a proposed change that this history renders between two stored versions.
What gets captured, and when
A version is a normalised photograph of the governance posture. The engine projects its live records into a stable, key-ordered shape, sorting every list by a stable key (an id, an email, a group or a name) so the same posture always serialises to the same bytes regardless of storage iteration order. Provenance fields such as who clicked save and exactly when are deliberately left out of the versioned shape, so re-saving an identical role does not churn a new version.
Capture happens on two triggers.
Automatically, after a committed config change
Every config-mutating method calls a best-effort snapshot wrapper after its write has committed. The snapshot is read-only observability taken after the fact, so it can never alter or block the change that triggered it, and a failure to capture it is swallowed to a log line rather than failing the mutation. The engine de-dupes the new snapshot against the current chain head by comparing the canonical bytes, so a change that leaves the versioned posture identical does not store a duplicate version.
On demand, with a manual snapshot
A
POST /config/snapshotcall captures the current posture immediately. It carries no body: the engine snapshots what is already stored, never a config you supply, so a manual snapshot is observability of the present state and not a way to write new config. It de-dupes against the head exactly like an automatic capture, so a manual snapshot of an unchanged posture returnscreated: falserather than churning a version.
The history is bounded so it cannot grow Durable Object storage without limit. The engine retains up to two thousand versions and rolls the oldest off once the count exceeds the cap. The sequence stays monotonic across a roll-over, so a rolled version’s id is never reused, and the retained chain stays internally verifiable from its earliest retained version.
Secrets are by reference only
A snapshot can never contain a secret value, by construction. The projection copies only a closed, named set of non-secret metadata, field by field, and never spreads an input object, so an unexpected field on a live record cannot ride into a version.
| Secret-adjacent field | What the version stores |
|---|---|
| A Secrets Store source secret | The secret’s name and the environment binding it is read through, never the value (the value lives in the account Secrets Store and is read at run time by binding) |
| A PagerDuty routing key on a notify channel | A presence boolean only, never the key |
| The legacy SRE-alert webhook | The destination host and a presence boolean, never the path, query, userinfo or full URL where a token usually hides |
The result is a record that an approver or an auditor can read in full without any risk of exposing a credential. The diff reads only this same named metadata, so a change line can say a secret was added by name without ever quoting its value.
The integrity model
Each stored version carries two cryptographic fields with different jobs. Do not conflate them.
The content hash commits to the exact posture. It is sha384: followed by the hex SHA-384 of the canonical JSON of the snapshot, the same hashing construction the audit chain and the archive fingerprints use. If a stored snapshot were edited in place, recomputing this hash would no longer match.
The signed digest binds the version to its place in the chain. It is HMAC-SHA-256 over the canonical JSON of the version’s chain-bound fields, keyed with the engine’s own in-Durable-Object signing key. The chain-bound fields are the id, the timestamp, the author, the summary, the content hash and the parent hash, but not the digest itself and not the raw snapshot bytes. Signing the content hash binds the snapshot transitively, so the digest does not need to re-hash the whole posture. A holder cannot forge a record that verifies without the key.
The content hash is SHA-384; the signature is HMAC-SHA-256
These are two different algorithms doing two different jobs. The content hash is SHA-384 over the snapshot. The signature is an HMAC keyed with SHA-256 over the chain-bound fields. There is no SHA-384 MAC anywhere in this design. The codebase deliberately uses SHA-384 for hashing to match the rest of its fingerprints, and HMAC-SHA-256 for the signature to match the session MAC exactly, because the signing key is the same key the session layer uses.
Each version’s parent hash is the previous version’s content hash, which is the chain link. The first version links to a fixed genesis sentinel (sha384: followed by ninety-six hex zeros), so deleting the first version is detectable.
The verify verdict
The verify verdict recomputes each version’s content hash and signed digest and compares them against the stored values to detect any change to the recorded configuration history. The history list route recomputes the entire chain under the in-Durable-Object key and reports the first break. A break is any of a recomputed content hash that does not match the stored one (the snapshot was edited), a recomputed digest that does not match (a chain-bound field was tampered or the record was forged without the key), a parent hash that does not equal the prior version’s content hash (a version was inserted, deleted or reordered), or an id that is not contiguous with its predecessor (a deleted middle version leaves a gap reported at the version after the hole). The verdict reports whether the chain is intact, the highest and lowest ids it checked, and the first broken id when there is one.
A documented retention roll-over is handled honestly: the engine expects the genesis link only when the earliest retained version is still id one, so a legitimate roll-over that pruned the genesis is not reported as a spurious break.
There is a second benign cause of a reported break, and it matters not to read it as tampering. The signed digest is keyed with the engine’s own in-Durable-Object signing key, the same key that signs sessions, and an owner-only terminate-all rotates that key to sign every live session out of the account. After a terminate-all, every version captured before the rotation was signed with the old key, so a verify recomputes their digests under the new key and reports them as broken even though nothing was altered. Treat the verify verdict for versions captured before a terminate-all as reset rather than as evidence of tampering. The full coupling is set out in the accordion at the foot of this page and in session management.
The four routes and who can call them
All four routes hang off the admin API. The three read routes gate on downpipe.read, which every built-in role holds, so any authenticated member who can view the configuration can read its history. The manual snapshot is a config-policy act, so it gates on access.policy, which means an owner or an access-admin.
| Route | Method | Gate | Returns |
|---|---|---|---|
/config/history |
GET | downpipe.read |
The version list newest-first, the chain head id and hash, and the verify verdict |
/config/version?id=N |
GET | downpipe.read |
One full version: the header plus the normalised snapshot |
/config/diff?from=A&to=B |
GET | downpipe.read |
The plain-English change list from version A to version B |
/config/snapshot |
POST | access.policy |
{ created: true, id, at, summary } for a new version, or { created: false } when the posture was unchanged |
The manual snapshot is not owner-only
A manual snapshot gates on access.policy, so an access-admin can take one as well as an owner. This differs from the change-control on-and-off toggle, which is owner-only. The engine re-resolves the caller’s authority from its own tables before it captures, as a defence-in-depth check on top of the router gate, and it records the requesting member as the version’s author. A break-glass bare-token caller has no attributable email, so its captures record a null author.
Reading the history
A GET /config/history call returns the version headers newest-first, the chain head, and the verify verdict. The list carries only the redaction-safe header fields, not the full snapshots.
GET /admin/config/history
{
"versions": [
{
"id": 42,
"at": "2026-06-19T01:12:44.881Z",
"author": "alex@example.com",
"parentHash": "sha384:9f3a...c1",
"contentHash": "sha384:7b20...e8",
"summary": "downpipe kv:sessions schedule hourly -> daily; alex@example.com: operator -> approver"
}
],
"headId": 42,
"headHash": "sha384:7b20...e8",
"verify": { "intact": true, "checkedThrough": 42, "earliestId": 1 }
}
| Field | Meaning |
|---|---|
versions[] |
The version headers newest-first; each carries the id, the capture time, the author or null, the parent hash, the content hash and the auto-summary |
headId / headHash |
The current chain head, or -1 and the genesis sentinel when no version exists yet |
verify.intact |
Whether the recomputed chain matched end to end |
verify.checkedThrough / verify.earliestId |
The highest and lowest ids the verify covered |
verify.brokenAt |
Present only when the chain is broken: the first failing id |
The plain-English diff
A GET /config/diff?from=A&to=B call loads both versions and runs the same diff logic the auto-summary uses. The diff direction is read from A to B as given, so you choose the direction by which id you pass as from. The change list is plain Australian English with an ASCII arrow for a transition.
GET /admin/config/diff?from=41&to=42
{
"found": true,
"from": 41,
"to": 42,
"changes": [
{ "kind": "changed", "area": "downpipe", "text": "downpipe kv:sessions schedule hourly -> daily" },
{ "kind": "changed", "area": "role", "text": "alex@example.com: operator -> approver" },
{ "kind": "added", "area": "notify-channel", "text": "notify channel On-call (slack) added" }
]
}
| Field | Meaning |
|---|---|
found |
Whether both from and to resolved to a stored version |
from / to |
The two version ids the diff was computed between, echoed back |
changes[].kind |
One of added, removed or changed, so a reader can group or icon a line without parsing the text |
changes[].area |
The config family the change is in, for example downpipe, role, custom-role, notify-rule or expiry |
changes[].text |
The plain-English one-liner, reading only named metadata and never a secret |
A schedule change renders as a friendly period where one applies, so a cadence in seconds becomes “hourly”, “daily” or “weekly” rather than a raw number. A no-expiry tracked item reads “no expiry” rather than a missing field. An absent or unknown from or to returns { found: false }, and a single-version read of an unknown id does the same.
Where this fits
- Change control is the opt-in four-eyes gate that holds a future config change for a second approval, and it renders the same plain-English diff over a proposed change that this history renders between two stored versions.
- Cloudflare config backup and restore protects your Cloudflare account configuration as a data source, which is a different thing entirely from the engine’s own governance config tracked here.
- Session management explains the in-Durable-Object signing key this history shares with the session layer, and why a terminate-all rotates it.
- Roles and capabilities defines the
downpipe.readandaccess.policycapabilities these routes gate on. - Admin endpoints reference catalogues these routes alongside the rest of the engine admin API.
The terminate-all coupling, in full
The signing key these versions are signed with is the engine’s own in-Durable-Object key, the very same key that signs sessions. An owner-only terminate-all rotates that key to sign every live session out of the account. A side effect worth knowing is that rotating the key also invalidates verification of any config-version digest that was recorded before the rotation: those versions were signed with the old key, so a verify after a terminate-all will report them as broken even though nothing was tampered. This is an accurate consequence of sharing one key, not a defect. If you run a terminate-all, treat the verify verdict for versions captured before that point as reset rather than as evidence of tampering. The session side of this coupling is covered in session management.
Last updated .