Approval gates
Overview
An approval gate holds a certificate job at pending_approval until an authorized workspace member explicitly approves it. A job in this state is invisible to agents: the claim path only ever selects jobs already at pending, so nothing runs until a human signs off. See Scheduled renewals and approvals for where this fits into the broader automation picture.
Approval gates are opt-in per job, not a workspace-wide setting:
- Manual jobs: set
requiresApproval: truewhen creating a job (dashboard Create manual job dialog on the Jobs tab of Certificate operations has a "Require approval before this job can run" checkbox, or set it directly in aPOST /api/v1/workspaces/{id}/certops/jobsrequest). - Bulk renew: set
requiresApproval: trueon aPOST /api/v1/workspaces/{id}/certops/jobs/bulk-renewrequest; every certificate in the batch gets the same gate. - Scheduled automation: renewal jobs created by the scheduler sweep do not currently support
requiresApproval. Gate a certificate's renewals today by using bulk renew (or a manual job) instead of waiting for the automatic sweep.
Without requiresApproval, a job starts at pending and is claimable immediately, exactly as before approval gates existed.
The non-requester rule
The person who requested a job cannot approve their own job. This is enforced server-side, not just in the UI:
- If the job was created by a signed-in user, that user's approval attempt is rejected with
403 CERTOPS_APPROVAL_SELF_APPROVAL_FORBIDDEN. Any other authorized member (workspace manager or admin) can approve it. - If the job was created through the API by a machine token rather than a session, there is no requester user to compare against, so any authorized member can approve it, including whoever operates the automation that created it. Treat API-token-originated jobs as needing the same human judgment at approval time that a manual job would.
- Rejection has no non-requester restriction. Any authorized member, including the original requester, can reject/withdraw a pending job. This is deliberate: you should always be able to cancel your own request.
What invalidates an approval
An approval is bound to the exact bytes of the job payload at the moment it was granted, not just to the job id:
- When you approve a job, TokenTimer computes a SHA-256 hash of the canonical job payload and stores it alongside the approval.
- Before an agent can claim the job, the same hash is recomputed from the payload as it stands right then.
- If the hashes differ, the job is flipped back to
pending_approvalautomatically, anapproval.invalidatedentry is written to the job log, and the job stays unclaimable until it is approved again.
In practice this rarely triggers today: no human-facing route mutates a job's payload after creation. It exists as defense in depth, so an approval can never be silently reinterpreted against different work than what was reviewed.
Approving or rejecting a job
Dashboard: open Certificate operations (reachable from the Control Center certificate-operations panel footer link or Workspace Preferences), go to the Jobs tab (/certops/jobs), and look for the job at status Pending approval. That row shows inline Approve and Reject buttons (only workspace managers/admins see them; other roles see the status badge only). Clicking either opens a small confirm dialog with an optional reason field before the decision is sent.
API: both actions require a workspace-manager (or admin) session; machine tokens cannot approve or reject.
POST /api/v1/workspaces/{id}/certops/jobs/{jobId}/approve
POST /api/v1/workspaces/{id}/certops/jobs/{jobId}/reject
Both accept an optional JSON body:
{ "reason": "Confirmed with the domain owner before renewing" }
reason is free text, capped at 1024 characters; omit it if you have nothing to add. Approving moves the job to pending (claimable); rejecting moves it directly to the terminal rejected status with error_code: "CERTOPS_APPROVAL_REJECTED".
Common error codes: 404 CERTOPS_JOB_NOT_FOUND, 409 CERTOPS_APPROVAL_JOB_NOT_PENDING_APPROVAL (the job already left pending_approval, for example a concurrent decision won the race), 403 CERTOPS_APPROVAL_SELF_APPROVAL_FORBIDDEN (approve only), 400 CERTOPS_APPROVAL_REASON_INVALID (reason not a string, or over the length cap), 400 CERTOPS_RENEWAL_PROFILE_INCOMPLETE (approve only, renew jobs, see below).
Approving a renew job needs a complete renewal profile
You cannot approve half a plan. Approving a renew job requires the job payload to carry a complete, valid renewalProfile snapshot; if it does not, the approval is refused with 400 CERTOPS_RENEWAL_PROFILE_INCOMPLETE and the job stays at pending_approval.
The reason is the whole point of an approval gate: what you approve is bound by hash to what will run. If the profile were resolved later, or read from a mutable certificate_profiles row at dispatch time, you would be signing off on an intent rather than on the actual work, and the two could diverge. So the profile is snapshotted into the payload and frozen.
Where this bites in practice:
- Scheduler-created renewals: nothing to do. The scheduler only creates a renewal job when the certificate already has a complete, validated profile, and it snapshots that profile into the payload at creation time.
- Manual and bulk-renew jobs: the profile is optional at creation time, so a thin
renewjob is accepted with201and then cannot be approved. If you plan to gate a manual renew withrequiresApproval, include the fullrenewalProfilein the creation payload.
A complete profile means all of: schemaVersion (currently 1), sanPolicy (with mode, a non-empty sans array, and allowWildcards), keyAlgorithm plus a matching keySize (RSA 2048/3072/4096 or ECDSA 256/384), keyRotationPolicy.rotateOnRenew, ca.endpoint (a valid URL), acme (kind of certbot or acme.sh, plus commandRef), dns (provider and zone), at least one entry in deploymentTargets, and verification (with requireMatch, and host when requireMatch is true).
The error message names the first field that failed, so fix them one at a time or copy a working profile from a scheduler-created job on a similar certificate.
Note that deciding an approval works even while the workspace kill switch is paused: the agent claim path is already blocked by the kill switch, so approving or rejecting during an incident cannot cause unwanted execution, and rejecting is exactly the kind of action an operator may need to take while paused.
Job log and audit trail
Every decision is recorded twice, atomically, in the same transaction as the status change:
- Job timeline (
GET .../jobs/{jobId}/log): anapproval.granted,approval.rejected, orapproval.invalidatedentry, with the reason (if any) and the deciding user. - Workspace audit log: the decision appears alongside other CertOps admin actions, searchable by job id.
API reference
POST /api/v1/workspaces/{id}/certops/jobs/{jobId}/approve— approve a pending-approval job (workspace manager+, session auth).POST /api/v1/workspaces/{id}/certops/jobs/{jobId}/reject— reject a pending-approval job (workspace manager+, session auth).GET /api/v1/workspaces/{id}/certops/jobs/{jobId}/log— job timeline, including approval decisions.
See Certificate Automation for the surrounding job/evidence API, and Executor jobs for job statuses and how jobs are created in the first place.