Skip to content
Solo ProTeamBusinessEnterprise

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#

bash
pulsvault versions acme-web/prod/product/web/db/password

Newest first, 25 per page by default:

text
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 versions

The 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 v9 can hold three versions.

When the page is short of the total, the line says so and tells you how to widen it:

text
412 versions — showing the newest 25; pass --limit N (max 100) for more

Like 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:

bash
pulsvault versions acme-web/prod/product/web/db/password -o json
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.

Three ways to ask, in increasing cost

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:

text
$ 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#

bash
pulsvault diff acme-web/prod/product/web/db/password --from 3 --to 5

By 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:

bash
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 list on the path, not read — 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 and removed could never appear, which would quietly make the commonest kind of change the one thing this command could not show you.
bash
pulsvault path-diff acme-web/prod/product/web --from 2026-08-01T00:00:00 -o json

Going back#

bash
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 one
Rollback appends, it never rewrites

Restoring 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:

bash
pulsvault rotate acme-web/prod/product/web/db/password
pulsvault rotate acme-web/prod/product/web/db/password --if-match 4

This is key hygiene, not credential change: the value your database expects does not move, but the key protecting it is replaced.

The plaintext never leaves the server during a rotate

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:

bash
NEW=$(openssl rand -base64 32)
# … change it at the provider first …
printf '%s' "$NEW" | pulsvault set acme-web/prod/product/web/db/password --from-stdin

Confirming 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:

bash
pulsvault validation-status acme-web/prod/product/web/db
text
acme-web/prod/product/web/db
  probe        postgres
  validation   enabled
  last result  SUCCESS (3s ago)
The check starts at the write, it does not finish with it

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:

bash
pulsvault validate acme-web/prod/product/web/db

It will not hand you the pre-rotation answer either: a write always forces a real probe rather than reporting a recent one.

Validation belongs to a path, not to the secret you wrote

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.

Was this page helpful?