Credential lifecycle — standing, rotation, and the road to leased secrets

How a submitted credential lives after it is fulfilled, and how it is rotated (issue #64, epic

42). For the retrieval path itself see runbook.md (Runtime retrieval via OIDC);

for the zero-knowledge guarantees see zero-knowledge-proof.md.

Standing credentials (the default, N-051)

A fulfilled intake is standing: the submitted value is retained in Vault and is readable by a bound reader at any time through the audited service path. This is deliberate — CI jobs and containers fetch the same credential across many runs, so it must persist. The operator still can never read it (Vault deny on the inbound path); persistence changes nothing about zero-knowledge.

Nothing extra is needed to "keep" a credential: once status = fulfilled, version = 1 and reads return the value until it is rotated or the intake is revoked.

Rotation (re-submission)

When the underlying secret changes at its source (a key is rolled, a token reissued), the operator rotates the intake instead of creating a new one — the id, display name, fields, and allowed_readers are preserved, so every bound reader keeps working without reconfiguration.

POST /api/v1/intakes/:id/rotate        (operator session)
  → issues a fresh submission token, sets status back to `pending`, version += 1
  → emails the recipient a new submission link
  → returns { id, intake_url, expires_at, version }

Flow:

  1. Re-open. The intake flips fulfilled → pending with a new JTI and a fresh 24h submission window. The old value stays in Vault (KV v2 keeps prior versions) until it is overwritten.
  2. Re-submit. The recipient submits the new value through the portal exactly as the first time. On success the value overwrites the Vault path, status → fulfilled, and rotated_at is stamped.
  3. Readers learn of it. Every service read returns version and rotated_at. A reader that caches a credential compares version and re-fetches when it increases — no prior value is ever exposed to detect the change.

Availability note. While a rotation is mid-flight (re-opened, not yet re-submitted) the service read returns 404 — the new value isn't in yet and the old one is being replaced. Rotate during a maintenance window, or (future work) serve the prior KV version until the new one lands.

Reader notification

Bound readers are machines, so "notification" is an auditable hook, not an email. On rotate the broker emits one reader.notify event per bound reader (metadata: { reader, version, reason: 'rotation' }) alongside the intake.rotate event. A deployment wires these to a webhook / SIEM / chatops channel to actively push the change; absent that, readers discover it via the version bump on their next read. The recipient (the human who holds the secret) is emailed the re-submission link.

Audit trail for one rotation:

intake.rotate   result=success  metadata={version:2}
reader.notify   result=success  metadata={reader:"ci", version:2, reason:"rotation"}
intake.submit   result=success            (when the recipient re-submits)

Future: dynamic / leased secrets (sketch, not built)

Standing + rotation covers credentials that live at a source system. The next step is credentials Sepulchre mints on demand with a TTL, so there is nothing standing to steal:

This is tracked as a later phase; the version / rotated_at fields and the reader.notify hook are the forward-compatible seams that leased credentials will reuse.