Versions & rotation
Reading a secret's history, comparing two versions or a whole path across time, restoring an earlier value, and re-keying a credential without moving its plaintext.
Commands on this page: versions · diff · path-diff · rollback · rotate
Every write creates a new immutable version. Nothing here ever rewrites history — the worst case is that history gets longer, which is exactly what you want from an audit trail.
What happened to this secret#
pulsvault versions acme-web/prod/product/web/db/passwordNewest first, 25 per page by default:
v5 2026-07-21T09:14:02 47B ci-bot@service-account
v4 2026-07-14T11:02:55 47B ci-bot@service-account
v3 2026-06-30T08:41:10 41B alice@acme.com
3 versionsThe top row is always the current version. That is a guarantee of the model, not a convention of
the output: rollback appends rather than rewrites, and a version that is current cannot be
destroyed. So the highest number is the live one, always. Numbers below it can have gaps — a pruned
or destroyed version leaves one — but nothing can ever sit above the current version.
How many versions are there#
The last line is the answer, and it is not the number of rows above it. Two things stop you counting them:
- the listing stops at
--limit, so a long history is cut off silently; - retention hard-deletes older versions, so the current version number is an upper bound on the
count rather than the count — a secret sitting at
v9can hold three versions.
When the page is short of the total, the line says so and tells you how to widen it:
412 versions — showing the newest 25; pass --limit N (max 100) for moreLike every other line the CLI adds for a reader, it goes to stderr — so pulsvault versions … | wc -l and $(…) still see exactly the table — and -q suppresses it.
In a pipeline#
-o json returns the page and the total together:
pulsvault versions acme-web/prod/product/web/db/password -o json{
"total": 412,
"returned": 25,
"truncated": true,
"versions": [
{ "versionNumber": 412, "valueSize": 47, "contentType": "text/plain",
"createdAt": "2026-07-21T09:14:02", "createdBy": "ci-bot@service-account" }
]
}So the count is .total, the newest version — to pin an --if-match on a subsequent write — is
.versions[0].versionNumber, and .truncated tells you whether raising --limit would show more.
No stderr line is printed in this mode; the total is already a field.
pulsvault get <path> already names the version it returned (v3 (current)) on stderr — no extra
call. pulsvault list <folder> shows every key's current version without reading a single value.
versions is the one that costs a request, and it is the only one that shows history or reports
a count. See Reading secrets.
:::caution version is not versions
pulsvault version — singular — prints the CLI's own build number and has nothing to do with any
secret. Give it a path and it refuses, rather than answering a question you did not ask:
$ pulsvault version acme-web/prod/product/web/db/password
error: `version` prints the CLI build version and takes no arguments
did you mean: pulsvault versions acme-web/prod/product/web/db/password:::
Comparing two versions#
pulsvault diff acme-web/prod/product/web/db/password --from 3 --to 5By default diff reports what changed and the sizes involved without printing either value, so it is
safe on a shared screen. Pass --reveal when you actually need to see the plaintext. With no --from /
--to it compares the two most recent versions.
Comparing a whole path across time#
A single secret has version numbers. A path does not — and that is deliberate: versions belong to keys here, not to folders, so two systems writing different keys under the same path never overwrite each other's work. A folder-wide version counter would have to be bumped by every write to every key beneath it, which is precisely the contention that model avoids.
What a folder-level version number would have been for is the question "what changed between then and
now". path-diff answers that directly:
pulsvault path-diff acme-web/prod/product/web/db --from 2026-08-01T00:00:00--from is required and --to defaults to now, both RFC3339. The output names each key that was
added, rewritten or removed between the two instants, with the version numbers on each side.
It is structural: it tells you which keys changed, never what they contain. Reading a value stays
pulsvault get, one key at a time, audited as a reveal. A whole-path comparison that printed plaintext
would put every credential under a folder on screen at once — which is exactly the thing get's masking
rules exist to prevent.
Three consequences worth knowing:
- "Changed" means a new version was written, not that the value is different. Writing the same bytes twice still shows as a change, because proving otherwise would mean decrypting both.
- It needs
liston the path, notread— it discloses names and version numbers only, so a key that may enumerate a path can also ask whether that path moved underneath it. - A deleted key shows as
removed. Deleting a secret is soft — every version row stays behind for the recovery window — so this reads the deletion record rather than the versions. Without that a deleted key would count as present at every later instant andremovedcould never appear, which would quietly make the commonest kind of change the one thing this command could not show you.
pulsvault path-diff acme-web/prod/product/web --from 2026-08-01T00:00:00 -o jsonGoing back#
pulsvault rollback acme-web/prod/product/web/db/password # to the previous version
pulsvault rollback acme-web/prod/product/web/db/password --to 3 # to a specific oneRestoring v3 writes its value as a new version (v6), leaving v4 and v5 in place. The fact that you rolled back stays visible in the history, which is the point — an audit trail that can be quietly edited is not one.
Rotating a data key#
rotate forces a new version with the same value under a fresh data key:
pulsvault rotate acme-web/prod/product/web/db/password
pulsvault rotate acme-web/prod/product/web/db/password --if-match 4This is key hygiene, not credential change: the value your database expects does not move, but the key protecting it is replaced.
It is a single server-side operation — the value is re-encrypted in place. Nothing is downloaded to your runner and pushed back, so the plaintext never appears in a CI environment and no spurious read lands in the audit log.
A key scoped to rotate alone is enough. It does not need read or write, so a scheduled rotation
job can hold a credential that is unable to read any secret it rotates. See
Access policies.
--if-match N refuses the rotation if the secret has moved past version N, which keeps a scheduled job
from racing a deploy that just wrote a new value.
Rotating the credential itself#
Changing the actual secret — a new database password, a reissued token — is a set (or gen), not a
rotate. The usual shape, with the old value still readable until the new one is confirmed working:
NEW=$(openssl rand -base64 32)
# … change it at the provider first …
printf '%s' "$NEW" | pulsvault set acme-web/prod/product/web/db/password --from-stdinConfirming the new value works#
If the credential's path is validated, the write already re-checks it. Every write of a new
version does this — set, gen, rotate, import, and edits made in the console — so a rotation is
confirmed working, or known broken, without you asking. Read the result:
pulsvault validation-status acme-web/prod/product/web/dbacme-web/prod/product/web/db
probe postgres
validation enabled
last result SUCCESS (3s ago)The probe runs after the new version commits, not inside it — the write returns as soon as the
value is stored. So a validation-status run in the same breath can still show the previous result,
for the second or two the probe takes.
That is why a rotation script that must not move on until it knows uses validate, which blocks:
pulsvault validate acme-web/prod/product/web/dbIt will not hand you the pre-rotation answer either: a write always forces a real probe rather than reporting a recent one.
There is no "validate this value" — a probe is attached to a path in the console, and it is either
a folder of keys that together form one credential (…/db, whose probe uses host, user,
password together) or a single key that is the credential (a PAT, an API key).
So name the validated path. Naming a key inside a validated folder works — it resolves up one level
and checks the whole connection — but a path with no probe configured anywhere above it is not
"passing", it is unchecked, and exits 4. Run validation-status first if you are unsure whether
a path is validated at all.
If the check fails, rollback puts the previous value back as a new version.
Retention#
A namespace can cap how many versions each secret keeps; each write then prunes the oldest beyond the cap, and the current version is never pruned. That is a namespace setting in the console — see Secrets & versioning.
Pruning is what puts gaps in the numbering, so under a cap the total reported by versions stops
tracking the current version number: a secret written 400 times under a cap of 25 sits at v400 and
reports 25 versions. The count is of what is kept, not of what was ever written.
Related#
- Writing secrets — creating the versions in the first place
- Validating credentials — proving a rotated value works
- Command reference — exact flags