Using Badge
No badge, no deploy
Block a deploy unless the agent's current Blueprint version carries a valid Signed Agent Record, with a GitHub Actions workflow you can paste, the exit codes it returns, the break-glass path, and the limits of what it proves.
A Signed Agent Record is Badge's signed proof of one measured run: which agent ran which task, whether the run succeeded, how it ran, and when. Anyone can check its signature against Badge's published public key. A record proves a measured result, nothing more. "No badge, no deploy" turns that proof into a gate: your pipeline refuses to ship an agent version that has no valid record.
An agent with no traced Blueprint version fails the gate. Until Badge has seen the agent run with tracing on, it has no Blueprint version, and there is nothing a record could be bound to. The gate says so in plain words: "This agent has no traced Blueprint version, so no signed record can be tied to it. Run it once with tracing on." To fix it, send the agent's traces to Badge and let one run complete (see OpenTelemetry). Then check again.
The gate belongs to Badge's approval policy. A workspace agent needs an active Team workspace. A personal agent's owner can run the check too, but a personal agent has no workspace audit log, so nothing is recorded. The gate is off unless your deployment turns it on. While it is off, the check endpoint answers 404 and the workflow below reports that the gate is not enabled. It does not fail your deploy.
What the gate checks
The gate asks for a valid record, not for a record. A failed run has a record too, and so does a simulated run. All of these must hold:
- the signature verifies, under Badge's active signing key. A record signed with a retired key does not pass;
- the run succeeded;
- the run was live. A simulated run never counts;
- the record carries a Blueprint version, and it is the agent's current Blueprint version;
- the record is no older than your limit, when your policy sets one. A record exactly at the limit still passes.
The score is not part of this check. To require a minimum score, set it in the same approval policy, where it composes with the signed-record gate. The policy's signed-record setting and its maximum record age also apply when someone requests or grants an approval. Turning the setting on ends any standing approval that carries no valid record, and the policy preview shows which ones before you save.
What the gate does not check
The gate checks the Blueprint version as Badge last observed it, from traces and runs. It does not read the code in your pull request. A pull request that changes the agent's architecture is not seen until a traced run of the new version lands in Badge, so a pipeline that runs the gate on the pull request itself can pass on the old version.
Two ways to close that gap:
- Run a traced screening of the new version before the gate, so the version Badge sees is the one you are shipping.
- In a pipeline that knows which version it is promoting, pin it with
BADGE_BLUEPRINT_VERSION_ID. The gate then checks that exact version and nothing else.
The gate is also not a runtime control. It stops a deploy. It does not stop an agent that is already running.
What a failure tells you
Badge states each failed gate in plain words. These are the ones you will meet:
| What you see | What to do |
|---|---|
| This agent has no traced Blueprint version, so no signed record can be tied to it. Run it once with tracing on. | Send traces, let a run complete, then check again. |
| No signed record exists for this agent's current Blueprint version. | Screen the agent so a run completes on this version. |
| The newest signed record is 41 days old; your policy allows 30. | Screen the agent again. |
| The newest signed record was signed with a retired key. | Screen the agent again for a record under the current key. |
| The newest signed record is for a run that failed. | Fix the agent, then screen it again. |
| The newest signed record is for a simulated run. Only live runs count. | Screen the agent against its live endpoint. |
| The newest signed record is not tied to a Blueprint version. Run the agent again with tracing on. | Send traces, then screen again. |
| The newest signed record does not verify, so Badge cannot vouch for it. | Screen the agent again. If it persists, contact support. |
| Badge could not check right now, so the gate stays closed. | Nothing is wrong with your agent. The job reports could not check, and fails only if you set BADGE_RECORD_FAIL_CLOSED. Run it again. |
Add the gate to GitHub Actions
The template below is two jobs. It has no push or tag trigger of its own, so it is not a deploy gate until you put it in your deploy workflow (step 4). As a separate file it runs on pull requests and on demand.
- Create a Badge API key in Settings. It needs the read scope, and it must belong to the workspace that owns the agent. Keep it read scope. Never reuse an admin-scope key here.
- In your repository, open Settings, then Secrets and variables, then Actions. Add the secret
BADGE_API_KEY. Add the variableBADGE_AGENT_IDwith the agent's id. - Save the workflow below as
.github/workflows/badge-record-gate.yml. Protect.github/workflows/with CODEOWNERS or a ruleset, because anyone who can merge a pull request can edit this gate. - To gate a deploy, copy the two jobs into your deploy workflow, set the variable
BADGE_RECORD_FAIL_CLOSEDtotrue, and make the deploying job depend on them:
deploy:
needs: [record-gate, record-gate-override]
if: "!cancelled() && (needs.record-gate.result == 'success' || needs.record-gate-override.result == 'success')"Exactly one of the two jobs runs on any given run, so the deploy proceeds when whichever ran succeeded. To use the gate as a pull request check instead, make record-gate a required status check on your deploy branch.
A pull request from a fork runs without your secrets, so the check cannot reach Badge and always reads "could not check". That is neutral by default. Do not rely on a pull request check alone to stop a deploy. Gate the deploy workflow, which runs on your own branches, with BADGE_RECORD_FAIL_CLOSED set to true.
| Name | Kind | What it does |
|---|---|---|
BADGE_API_KEY | repository secret | Your Badge API key, read scope. The workflow reads it only from secrets, and never prints it. |
BADGE_OVERRIDE_API_KEY | secret of the badge-override Environment, optional | A key with the admin scope, used only for a break-glass override. Not a repository secret. The check job never sees it. |
BADGE_AGENT_ID | variable | The agent to check. |
BADGE_BLUEPRINT_VERSION_ID | variable, optional | Pin one Blueprint version. Empty or auto means the agent's current version. |
BADGE_MAX_RECORD_AGE_DAYS | variable, optional | An age limit from 1 to 3650 days for this check. It can only tighten your workspace policy's limit, never loosen it. Empty means the policy's limit. |
BADGE_RECORD_FAIL_CLOSED | variable, optional | true makes a check that could not run fail the job. Default false. Set it to true in a deploy workflow. |
# .github/workflows/badge-record-gate.yml
#
# No badge, no deploy. Fails the job unless the agent's current Blueprint
# version carries a VALID Signed Agent Record under your workspace's policy: a
# verified signature under Badge's active key, a successful live run, a
# Blueprint version the record is bound to, and a record fresh enough for your
# policy. A record that merely exists does not pass.
#
# An agent with no traced Blueprint version fails with a plain reason: Badge
# has not yet seen it run, so there is nothing to bind a record to.
#
# The gate checks the Blueprint version Badge last observed, not the code in
# this pull request. Pin BADGE_BLUEPRINT_VERSION_ID in a pipeline that knows
# which version it is promoting.
#
# What the check job returns:
# passed job green
# did not pass job red, with Badge's plain-words reason
# refused job red, with Badge's reason: a revoked or wrong key (401, 403),
# no Team plan (402), a bad request (422), a wrong agent id (404),
# or a malformed response (a 200 without a verdict is a fault)
# could not check job green with a warning, because Badge did not answer: a
# 429, a 5xx, a network error, or a deployment where record gates
# are off. Set BADGE_RECORD_FAIL_CLOSED to true to make it red.
# A pull request from a fork gets no secrets, so it always reads
# this way; do not rely on this job as a PR check for deploys.
#
# This file has no push or tag trigger. To gate a deploy, copy the two jobs below
# into your deploy workflow, set BADGE_RECORD_FAIL_CLOSED to true there, and add:
# needs: [record-gate, record-gate-override]
# if: "!cancelled() && (needs.record-gate.result == 'success' || needs.record-gate-override.result == 'success')"
# to the job that deploys. Protect .github/workflows/ with CODEOWNERS, because a
# pull request can edit this gate. Keep BADGE_API_KEY a read-scope key.
#
# Break-glass: run this workflow by hand (Run workflow) and pick an override
# reason. That starts only the second job, which runs in the GitHub Environment
# badge-override. Give that Environment required reviewers and limit it to your
# default branch, and store BADGE_OVERRIDE_API_KEY there as an Environment secret:
# a key with the admin scope, owned by a workspace owner or admin. The workspace
# audit log must be on, and the override is logged there. The first job never sees
# that key, and an override that Badge does not accept leaves the job red.
#
# Repository secret: BADGE_API_KEY a Badge API key with the read scope
# Environment secret (badge-override):
# BADGE_OVERRIDE_API_KEY an admin-scope key, used only to override
# Repository variables: BADGE_AGENT_ID your agent's id
# BADGE_BLUEPRINT_VERSION_ID optional, defaults to the current version
# BADGE_MAX_RECORD_AGE_DAYS optional, 1 to 3650; can only tighten your policy's limit
# BADGE_RECORD_FAIL_CLOSED optional, true or false (default false)
name: Badge record gate
on:
pull_request:
workflow_dispatch:
inputs:
override_reason:
description: Break-glass only. Owners and admins; logged in the audit log.
type: choice
default: ""
options:
- ""
- incident_hotfix
- record_service_unavailable
- key_rotation
- other
permissions: {}
jobs:
record-gate:
if: github.event_name != 'workflow_dispatch' || inputs.override_reason == ''
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
outcome: ${{ steps.gate.outputs.outcome }}
steps:
- name: Require a valid signed Badge record
id: gate
env:
BADGE_API_URL: https://api.badgeia.com
BADGE_API_KEY: ${{ secrets.BADGE_API_KEY }}
BADGE_AGENT_ID: ${{ vars.BADGE_AGENT_ID }}
BADGE_BLUEPRINT_VERSION_ID: ${{ vars.BADGE_BLUEPRINT_VERSION_ID }}
BADGE_MAX_RECORD_AGE_DAYS: ${{ vars.BADGE_MAX_RECORD_AGE_DAYS }}
BADGE_RECORD_FAIL_CLOSED: ${{ vars.BADGE_RECORD_FAIL_CLOSED }}
run: |
set -uo pipefail
TITLE="title=Badge record gate"
CLOSED=false
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
# One line, no secret, no forged workflow command.
clean() {
local text="${1//${BADGE_API_KEY:-}/[redacted]}"
printf '%s' "$text" | tr '\r\n' ' ' | cut -c1-300
}
# A repository mistake, or Badge refusing the call, is a red job: a gate that
# quietly passes on a typo or a revoked key is worse than none.
refuse() {
echo "outcome=error" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "::error $TITLE::$1"
exit 1
}
# No verdict because Badge did not answer. Green with a warning, unless the
# repository chose to fail closed.
unchecked() {
echo "outcome=could_not_check" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Could not check: $1" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
if [ "$CLOSED" = true ]; then
echo "::error $TITLE::Could not check: $1. BADGE_RECORD_FAIL_CLOSED is true, so the gate stays closed."
exit 1
fi
echo "::warning $TITLE::Could not check: $1. This is not a gate failure, so the job continues. Set BADGE_RECORD_FAIL_CLOSED to true to block instead."
exit 0
}
# What the route said, from a body that is not trusted to be JSON.
detail() {
clean "$(jq -r 'if (.detail | type) == "object" then .detail.message else .detail end // empty' "$BODY" 2>/dev/null || true)"
}
case "${BADGE_RECORD_FAIL_CLOSED:-}" in
true) CLOSED=true ;;
false|"") ;;
*) refuse "BADGE_RECORD_FAIL_CLOSED must be true or false." ;;
esac
if ! [[ "${BADGE_AGENT_ID:-}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
refuse "BADGE_AGENT_ID must be set to your agent's id (a UUID)."
fi
VERSION="${BADGE_BLUEPRINT_VERSION_ID:-}"
if [ "$VERSION" = auto ]; then VERSION=""; fi
if [ -n "$VERSION" ] && ! [[ "$VERSION" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
refuse "BADGE_BLUEPRINT_VERSION_ID must be a Blueprint version id (a UUID), or empty for the current version."
fi
MAX_AGE="${BADGE_MAX_RECORD_AGE_DAYS:-}"
if [ -n "$MAX_AGE" ] && { ! [[ "$MAX_AGE" =~ ^[0-9]{1,4}$ ]] || [ "$MAX_AGE" -lt 1 ] || [ "$MAX_AGE" -gt 3650 ]; }; then
refuse "BADGE_MAX_RECORD_AGE_DAYS must be a whole number from 1 to 3650."
fi
if [ -z "${BADGE_API_KEY:-}" ]; then
unchecked "BADGE_API_KEY is empty (a pull request from a fork gets no secrets)"
fi
REQUEST=$(jq -n --arg version "$VERSION" --arg age "$MAX_AGE" '
{surface: "action"}
+ (if $version != "" then {blueprint_version_id: $version} else {} end)
+ (if $age != "" then {max_record_age_days: ($age | tonumber)} else {} end)')
HTTP=$(curl -sS -o "$BODY" -w '%{http_code}' --max-time 30 -X POST \
"$BADGE_API_URL/api/v1/agents/$BADGE_AGENT_ID/record-check" \
-H "X-API-Key: $BADGE_API_KEY" -H "Content-Type: application/json" \
-d "$REQUEST") || HTTP=000
case "$HTTP" in
200) ;;
000) unchecked "could not reach Badge" ;;
429) unchecked "Badge rate-limited this API key (HTTP 429)" ;;
5??) unchecked "Badge answered HTTP $HTTP: $(detail)" ;;
404)
if [[ "$(detail)" == *"not enabled"* ]]; then
unchecked "record gates are not enabled on this Badge deployment (HTTP 404)"
fi
refuse "HTTP 404: $(detail). The agent is unknown, or this key cannot see it." ;;
*) refuse "Badge refused the check. HTTP $HTTP: $(detail)" ;;
esac
PASSED=$(jq -r 'if type == "object" and (.passed | type) == "boolean" then .passed else "invalid" end' "$BODY" 2>/dev/null || echo invalid)
REASONS=$(jq -r '.gates[]? | select(.passed == false) | (.reason // ("gate " + (.gate // "unknown") + " failed")) | tostring | gsub("[\\r\\n]"; " ")' "$BODY" 2>/dev/null || true)
if [ -z "$REASONS" ]; then REASONS="the gate did not pass"; fi
case "$PASSED" in
true)
RECORD=$(clean "$(jq -r '.record.id // empty' "$BODY" 2>/dev/null || true)")
echo "outcome=pass" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Badge record gate passed${RECORD:+ (record $RECORD)}."
echo "Badge record gate passed${RECORD:+ (record $RECORD)}." >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
;;
false)
echo "outcome=fail" >> "${GITHUB_OUTPUT:-/dev/null}"
while IFS= read -r REASON; do
echo "::error $TITLE::$(clean "$REASON")"
echo "- $(clean "$REASON")" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
done <<< "$REASONS"
exit 1
;;
*) refuse "Malformed response: Badge's answer was not the documented record-check shape." ;;
esac
record-gate-override:
if: github.event_name == 'workflow_dispatch' && inputs.override_reason != ''
runs-on: ubuntu-latest
timeout-minutes: 5
environment: badge-override
outputs:
outcome: ${{ steps.override.outputs.outcome }}
steps:
- name: Break-glass override of the Badge record gate
id: override
env:
BADGE_API_URL: https://api.badgeia.com
BADGE_OVERRIDE_API_KEY: ${{ secrets.BADGE_OVERRIDE_API_KEY }}
BADGE_AGENT_ID: ${{ vars.BADGE_AGENT_ID }}
BADGE_BLUEPRINT_VERSION_ID: ${{ vars.BADGE_BLUEPRINT_VERSION_ID }}
BADGE_MAX_RECORD_AGE_DAYS: ${{ vars.BADGE_MAX_RECORD_AGE_DAYS }}
BADGE_OVERRIDE_REASON: ${{ inputs.override_reason }}
run: |
set -uo pipefail
TITLE="title=Badge record gate"
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
clean() {
local text="${1//${BADGE_OVERRIDE_API_KEY:-}/[redacted]}"
printf '%s' "$text" | tr '\r\n' ' ' | cut -c1-300
}
# An override that Badge did not accept authorised nothing and wrote no audit
# event, so every such outcome is a red job, whatever BADGE_RECORD_FAIL_CLOSED says.
refuse() {
echo "outcome=error" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "::error $TITLE::$1 The override was not accepted, so nothing was authorised."
exit 1
}
detail() {
clean "$(jq -r 'if (.detail | type) == "object" then .detail.message else .detail end // empty' "$BODY" 2>/dev/null || true)"
}
case "${BADGE_OVERRIDE_REASON:-}" in
incident_hotfix|record_service_unavailable|key_rotation|other) ;;
*)
echo "outcome=error" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "::error $TITLE::The override reason must be one of: incident_hotfix, record_service_unavailable, key_rotation, other."
exit 1 ;;
esac
if ! [[ "${BADGE_AGENT_ID:-}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
refuse "BADGE_AGENT_ID must be set to your agent's id (a UUID)."
fi
VERSION="${BADGE_BLUEPRINT_VERSION_ID:-}"
if [ "$VERSION" = auto ]; then VERSION=""; fi
if [ -n "$VERSION" ] && ! [[ "$VERSION" =~ ^[0-9A-Fa-f-]{36}$ ]]; then
refuse "BADGE_BLUEPRINT_VERSION_ID must be a Blueprint version id (a UUID), or empty for the current version."
fi
MAX_AGE="${BADGE_MAX_RECORD_AGE_DAYS:-}"
if [ -n "$MAX_AGE" ] && { ! [[ "$MAX_AGE" =~ ^[0-9]{1,4}$ ]] || [ "$MAX_AGE" -lt 1 ] || [ "$MAX_AGE" -gt 3650 ]; }; then
refuse "BADGE_MAX_RECORD_AGE_DAYS must be a whole number from 1 to 3650."
fi
if [ -z "${BADGE_OVERRIDE_API_KEY:-}" ]; then
refuse "BADGE_OVERRIDE_API_KEY is empty. Store it as a secret of the badge-override Environment: a key with the admin scope, owned by a workspace owner or admin."
fi
REQUEST=$(jq -n --arg version "$VERSION" --arg age "$MAX_AGE" --arg override "$BADGE_OVERRIDE_REASON" '
{surface: "action", override: {reason_code: $override}}
+ (if $version != "" then {blueprint_version_id: $version} else {} end)
+ (if $age != "" then {max_record_age_days: ($age | tonumber)} else {} end)')
HTTP=$(curl -sS -o "$BODY" -w '%{http_code}' --max-time 30 -X POST \
"$BADGE_API_URL/api/v1/agents/$BADGE_AGENT_ID/record-check" \
-H "X-API-Key: $BADGE_OVERRIDE_API_KEY" -H "Content-Type: application/json" \
-d "$REQUEST") || HTTP=000
case "$HTTP" in
200) ;;
000) refuse "Badge did not answer: could not reach Badge." ;;
*) refuse "Badge answered HTTP $HTTP: $(detail)." ;;
esac
PASSED=$(jq -r 'if type == "object" and (.passed | type) == "boolean" then .passed else "invalid" end' "$BODY" 2>/dev/null || echo invalid)
REASONS=$(jq -r '.gates[]? | select(.passed == false) | (.reason // ("gate " + (.gate // "unknown") + " failed")) | tostring | gsub("[\\r\\n]"; " ")' "$BODY" 2>/dev/null || true)
if [ -z "$REASONS" ]; then REASONS="the gate did not pass"; fi
case "$PASSED" in
true)
if [ "$(jq -r '.overridden // false' "$BODY" 2>/dev/null)" = true ]; then
echo "outcome=override" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "::warning $TITLE::Overridden ($BADGE_OVERRIDE_REASON): this job passed by break-glass override, not because the gate was met. The real result follows."
while IFS= read -r REASON; do
echo "::warning $TITLE::$(clean "$REASON")"
echo "- $(clean "$REASON")" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
done <<< "$REASONS"
else
echo "outcome=pass" >> "${GITHUB_OUTPUT:-/dev/null}"
echo "Badge record gate passed on its merits; no override was needed."
fi
;;
false)
echo "outcome=fail" >> "${GITHUB_OUTPUT:-/dev/null}"
while IFS= read -r REASON; do
echo "::error $TITLE::$(clean "$REASON")"
done <<< "$REASONS"
exit 1
;;
*) refuse "Malformed response: Badge's answer was not the documented record-check shape." ;;
esacThe workflow needs only curl and jq, which GitHub's Ubuntu runners include. It asks for no token permissions, and it does not run on pull_request_target.
What the job returns
| Badge says | The job | Annotation |
|---|---|---|
| The agent has a valid record that meets your policy. | Green. | None. |
| A gate did not pass. | Red. | One error per failed gate, in Badge's words. |
| Badge refused the call: a revoked or wrong key (401, 403), a workspace with no active Team plan (402), a bad request (422), or an agent id Badge does not know (404). | Red. | An error with Badge's reason. |
| Badge did not answer: a 429, a 5xx, a network error, or a 404 that says record gates are not enabled. | Green, with a warning. Red when BADGE_RECORD_FAIL_CLOSED is true. | A warning that says the check did not run and why. |
| No secret reached the job, which is what happens on a pull request from a fork. | Same as the row above. | The warning names BADGE_API_KEY. |
| A 200 that is not the documented shape. Badge never returns a 200 without a verdict, so this is a fault. | Red. | An error that says malformed response. |
| A break-glass override was accepted. | Green, in the override job. | A warning that says the job passed by override, then the real failures. |
| A break-glass override was not accepted, for any reason. | Red, whatever BADGE_RECORD_FAIL_CLOSED says. | An error with Badge's reason. |
| A repository variable has a bad value. | Red. | An error that names the variable. |
Could not check is not a gate failure, and a refused call is not a could-not-check. Runners share network addresses, so a rate limit on one job must never fail someone else's deploy. A revoked key or a mistyped agent id is different: it is a mistake, and a gate that passes quietly on a mistake is no gate. By default Badge being unreachable leaves your pipeline as it was. If your rule is that an unchecked deploy must not ship, set BADGE_RECORD_FAIL_CLOSED to true, and the same cases fail the job. Set it in your deploy workflow.
A 200 is the only verdict. Each job writes outcome as pass, fail, override, could_not_check or error. In the same workflow, needs.record-gate.outputs.outcome lets a later step act on it. For example, a deploy job can require pass exactly.
The exit codes are the same wherever you run the check:
| Exit code | Meaning |
|---|---|
| 0 | The gate passed, or an authorised override was accepted. |
| 1 | The gate did not pass. Or Badge refused the call (401, 402, 403, 422, any 404 except "not enabled", a malformed 200). Or an override was not accepted. Or the check could not be set up: no key, a bad URL or agent id, a usage error. |
| 2 | Could not check, and only that: Badge did not answer (a 429, a 5xx, a network error, or a 404 that says record gates are not enabled). |
Use it from another CI system
The workflow makes one call. You can make it from any runner with curl:
curl -sS -X POST "https://api.badgeia.com/api/v1/agents/$BADGE_AGENT_ID/record-check" \
-H "X-API-Key: $BADGE_API_KEY" -H "Content-Type: application/json" -d '{"surface":"cli"}'An HTTP 200 with "passed": true is a pass, and "passed": false is a failure that lists the gates. Treat a 429, a 5xx or a network error as could not check, and never as a failed gate. Treat a 401, 402, 403, 422, any 404 other than "record gates are not enabled", or a 200 that is not this shape as a refusal: fail the step and show Badge's reason. The body accepts these optional fields: blueprint_version_id, max_record_age_days, surface (cli or action, which is what the audit log records) and override.
Break-glass
Sometimes a deploy has to ship without a valid record: an incident hotfix, a key rotation, or Badge being down. The gate is a check, not a lock, and the way around it is deliberate and leaves a trail.
- Who may. Only a workspace owner or admin. The role checked is the caller's, or for an API key, its owner's. A member gets a 403 and the gate does not pass.
- The key must say so. With an API key, the key itself must carry the
adminscope: "Overriding a record gate with an API key needs a key with the admin scope." A pipeline key is shared by everyone who can run the workflow, so the everyday key should be read scope and never able to override. - What it needs. A workspace agent, and the workspace audit log switched on, because an override is logged there. Without the audit log the override is refused with a 422.
- How. Set up the break-glass Environment once. In your repository, open Settings, then Environments, and create
badge-override. Add required reviewers, and under deployment branches allow only your default branch. StoreBADGE_OVERRIDE_API_KEYthere as an Environment secret, never as a repository secret: an admin-scope key owned by an owner or admin. To override, run the workflow by hand from the default branch with Run workflow and pick a reason. Only these reasons are accepted:incident_hotfix,record_service_unavailable,key_rotationorother. Anything else is rejected. This starts only the override job. If you configured required reviewers on the Environment, it waits for one of them to approve before it runs. GitHub records who ran it and, when reviewers are required, who approved. The job passes with a warning that says it passed by override, and still prints the real failures. - If Badge does not accept it, the job is red. A member's role, a read-scope key, a revoked key, an audit log that is off, a network error: none of these is a pass, and none leaves a trail in Badge, so the job fails whatever
BADGE_RECORD_FAIL_CLOSEDsays. - In your own tooling. Send
{"override": {"reason_code": "incident_hotfix"}}to the check with an admin-scope key.
Every override is one record_gate.decided entry in the audit log, with the reason you gave and the gate it overrode. Approvals have no override: to approve an agent that does not meet the gate, loosen the policy. That change is audited too.
The template keeps the override key out of pull requests and out of the check job, and no repository variable can turn an override on, so one cannot be left switched on. Whether an override needs a second person's approval, and whether it can run from a branch other than your default branch, depends on you configuring required reviewers and the default-branch restriction on the badge-override Environment. Badge and the template cannot check that you did.
What gets logged
When the audit log is on, every gate decision for a workspace agent writes a record_gate.decided entry. It records the surface (approval, cli or action), the outcome (pass, fail, override or could_not_check), the gate, the agent, the Blueprint version, the record id, a reason code and, when an API key made the call, the id of that key (api_key_id). On an override, the entry also carries the gate failure that was overridden. It never records free text. A check that could not be made because Badge's signing was unavailable is logged too. A personal agent belongs to no workspace, so the check still answers pass or fail, writes no entry, and says so.
Related
- Approved for production: the policy this gate belongs to.
- Audit log: where gate decisions are recorded.
- OpenTelemetry: how an agent gets a traced Blueprint version.