Short-Lived, Per-Project Storage Credentials — Phase 4: Runner-side refresh loop

Part of short-lived-storage-credentials-00-overview.md — read that first for goal, background, and architecture summary. This is Phase 4 of 4, the final phase.

Depends on: Phase 3 (short-lived-storage-credentials-03-credential-strategies.md) — needs ShortLivedStrategy and the protocol handler registry in place.

Required for ShortLivedStrategy regardless of cluster count or topology (established: token lifetime is a function of job duration vs. issuer cap, not of where the job runs).

This is the only phase that changes runtime behavior for real jobs, and only for StorageBackend rows explicitly configured with credential_strategy: short-lived — the bootstrap row stays static-key by default, so nothing changes for existing deployments until an admin opts a backend in.

4.1 Per-job refresh token

At job launch (job_orchestrator.create_job_manifest), alongside the minted storage credential, generate a random opaque REFRESH_TOKEN (e.g. secrets.token_urlsafe(32)), store its hash on ProcessVersion (new column refresh_token_hash), and inject the plaintext as an env var (STORAGE_REFRESH_TOKEN) into the pod — same delivery mechanism as every other env var already injected today, no new distribution channel.

This sidesteps validating a Kubernetes ServiceAccount token across cluster boundaries (which would require the backend to trust each cluster's own OIDC issuer — real complexity once multi-cluster-execution.md lands) in favor of a credential-agnostic opaque secret that works identically regardless of which cluster the pod is in.

4.2 New backend endpoint

Method Path Auth Description
POST /internal/process/{process_id}/versions/{version}/storage-credentials/refresh STORAGE_REFRESH_TOKEN header, hash-compared Re-mints and returns a fresh storage credential for this job

Backend re-runs strategy.mint(project, backend) and returns the new credential + its expires_at. Rate-limited/backed off gracefully — a transient failure here must not fail a 36h job outright (§4.4).

4.3 Runner changes — refresher runs as a separate OS process, not a thread

docker/base-runner/runner.py's main() today runs process_class.run() synchronously as the only process in the container (confirmed: no threading/multiprocessing exists there yet). The refresh loop must not be a background thread in that same process:

4.4 Failure modes to design for explicitly

Next

This is the final phase. See short-lived-storage-credentials-00-overview.md's Open Questions section for unresolved design points that may need follow-up work.