Skip to content
GitHub
Operator

Secrets management runbook

How Spectral’s secrets are sourced, published, rotated, and audited. The model is ADR-110: secrets live in 1Password as op://Spectral/<item>/<field> references in infra/environments.toml; tools/provision/provision.sh --env <name> resolves them and publishes to the matching GitHub Environment; the GitHub Actions deploy reads that Environment and applies it to the live systems (wrangler sets the container’s secrets/variables; the Supabase CLI applies migrations). CI secrets handling is ADR-062.

Secret classes

  1. Platform operational secrets — provider/observability keys, Supabase keys + DSNs, the Cloudflare deploy token. Stored as op:// refs in infra/environments.toml; published to the GitHub Environment by provision.sh.
  2. Customer BYO credentials — post-alpha; tenant data, not operational config. Not handled here (lands in core.domain_secrets via Supabase Vault; the ADR-035 D4 / ADR-037 reservation).
  3. CI secrets — scoped to GitHub Environments (production; staging when the stage project lands) per ADR-062; live-secret runs gated to non-PR triggers.
  4. Local dev — local Supabase + dev-tier keys in a gitignored .env; provision.sh --env dev publishes the [dev] table to it. Dev laptops never hold production secrets.

The model (ADR-110)

  • infra/environments.toml is the single source: non-secret values are literals (committed); secrets are op://Spectral/<item>/<field> refs (resolved at use, never committed).
  • provision.sh --env production resolves the table (op-injected) and publishes — an op://-sourced value becomes a GitHub secret; a literal becomes a GitHub variable.
  • The deploy workflow reads the Environment and pushes to the live systems (wrangler sets the container’s secrets/variables; the Supabase CLI applies migrations from the direct/session-pooler DSN).
  • There is no .env.provision cache, no per-secret --mode flow, no Render Environment Group, no GCP Secret Manager.

Standard rotation

To rotate a value (e.g. ANTHROPIC_API_KEY, the Supabase service key, the Cloudflare token):

  1. Mint the new value at the vendor.
  2. Update the value in 1Password (the item/field the op:// ref points at). The committed environments.toml ref does not change.
  3. Re-publish: tools/provision/provision.sh --env production (re-resolves every key and overwrites the GitHub secret/variable). Dry-run first with --env production --dry-run.
  4. Redeploy (push production) so the new value reaches the live systems.
  5. Revoke the old value at the vendor.

A value shared across environments lives in one 1Password item; re-run provision.sh for each environment that references it.

Generation + rotation with --rotate

For a Spectral-minted secret (one we generate rather than fetch from a vendor) — or any secret you’d rather not mint and paste by hand — provision.sh --rotate <KEY> does the whole loop: produce a new value, store it in 1Password, and re-publish the affected environment(s). First-time generation is just the first rotation.

tools/provision/provision.sh --rotate SPECTRAL_DELEGATION_SIGNING_KEY # mint + store + republish
tools/provision/provision.sh --rotate SPECTRAL_DELEGATION_SIGNING_KEY --dry-run # preview; writes nothing

How a key behaves is declared in the reserved [rotation] table in infra/environments.toml:

[rotation]
# KEY = "<generator|prompt>:<shared|per-env>"
SPECTRAL_DELEGATION_SIGNING_KEY = "es256-jwk:shared"
  • Generator — a registered generator in tools/provision/generators/ (e.g. es256-jwk, which mints the ES256 delegation signing keypair) produces the value; prompt means enter it interactively (for vendor-minted secrets you paste in).
  • Scopeshared rotates one 1Password item used by every referencing environment and republishes them all; per-env rotates a distinct item per environment and requires --env <name>, republishing that one.

--rotate overwrites a live secret, so it shows the plan (key, generator, scope, op target, affected envs) and prompts before writing (--yes to skip). Generation is explicit-only — a normal provision.sh --env <e> publish re-publishes existing values and never regenerates.

Rolling restart: rotating updates 1Password + the GitHub Environment, not the running system. The new value reaches services on the next deploy (fast-forward-push the production branch). A secret read once at boot — the delegation signing key — requires the container to recycle before the new key takes effect.

Generators

A generator is a script under tools/provision/generators/ that emits the new secret value on stdout (nonzero exit = fail loud). Python generators run in the project venv via uv run python <file> (so dependencies like cryptography resolve — no execute bit needed). The first is es256_jwk.py (the ES256 delegation signing key, SPEC-720 / SPEC-555); add a new one by dropping in a script and naming it in the key’s [rotation] spec. The metadata reads (scope, op target, referencing envs) live in tools/provision/_rotation.py — the shell owns the op/gh calls.

Emergency rotation

On suspected compromise / leaked value / vendor-reported anomalous usage:

  1. Identify blast radius — rotate every secret the suspected identity could reach.
  2. For each: mint new → update in 1Password → provision.sh --env <env> → redeploy.
  3. Revoke old values at the vendors immediately (revocation is idempotent with rotation).
  4. Log an incident in Linear with the rotated secrets + the provisioning/deploy run.
  5. Review the audit surfaces below for anomalous reads in the suspected window.

Audit trail

SurfaceRecordsWhere
Git historyenvironments.toml + provision/deploy changesGitHub commits
1Password item historysecret value changes1Password (vault Spectral)
GitHub audit logEnvironment secret/variable mutations, Environment access, deploy runsGitHub org admin
Supabase logsdashboard config + DB access eventsSupabase dashboard
Cloudflare audit logtoken use, Worker/secret changesCloudflare dashboard

Ownership

The builder running provision.sh is the sole operator for platform secrets at alpha; expand to a named rotation roster (two minimum) as the team grows. Known op coordinates: the Cloudflare deploy token is the consolidated op://Spectral/cloudflare/api-token (shared with the Codex Pages deploy, so it carries the union of scopes — Pages + Workers/Containers/R2/Routes); the Supabase production DSN/keys are under op://Spectral/supabase-production/*.

BYO customer credentials (post-alpha)

Not handled by this runbook or provision.sh — customer-provided keys are tenant data. When BYOK ships: encrypted blobs in a platform-scoped core.domain_secrets table, access via a SECURITY DEFINER function checking auth.uid() against domain_members, AEAD via Supabase Vault’s libsodium backing with domain_id as AAD (the ADR-037 reservation).

  • ADR-110 — provisioning + the env-config model.
  • ADR-062 — CI secrets handling (GitHub Environment scoping).
  • ADR-035 — provider key context; D4 BYO reservation.
  • tools/provision/README.md — provisioning-script operator documentation.