Base-controlled gate observation and authority migration¶
Issue #3136 records a circular trust boundary: a pull request can
modify the same pull_request workflow or helper that publishes its required quick or Agent Review Gate success.
Stage 1 is observation-only¶
Stage 1 leaves branch protection and the legacy publishers unchanged. The two new commit-status contexts are telemetry and must never be configured as required checks:
Promotion prohibition: *-observe must never be configured as required checks.
| Context | Producer | Candidate execution | Required |
|---|---|---|---|
quick |
legacy workflow or docs-only publisher | legacy PR workflow | yes, app 15368 |
Agent Review Gate |
legacy PR/comment workflow | API-only, with candidate-controlled PR source | yes, app 15368 |
quick-v2-observe |
base-owned API metadata observer | none | no |
Agent Review Gate v2 observe |
base-owned API-only observation workflow | none | no |
GitHub branch protection can bind a status/check to a context and app ID, but every repository workflow publishes as
GitHub Actions app ID 15368. A candidate-controlled workflow can publish either observation context with the same
identity. Context plus app ID does not prove which workflow made the decision. The *-observe names make their
non-authoritative role explicit; no protection-switch command targets them.
The quick v2 workflow is metadata-only and schedules zero self-hosted jobs. It never checks out or executes pull-request
or fork code and never reads a candidate artifact, log, or cache. Its pull_request_target path initializes opened,
reopened, synchronized, ready, draft, and base-edited pull requests; title- and body-only edits are no-ops. Its
workflow_run path consumes completed runs from the canonical legacy Tests (quick) workflow and ignores main pushes.
The read-only producer and status publisher independently run .github/scripts/quick-v2-metadata-policy-v1.js: assess.
For a completed legacy run, .github/scripts/quick-v2-metadata-policy-v1.js: bindRunPullRequest requires exactly one
recorded run.pull_requests entry and binds its PR number, head, base, and repository identity. The current open PR
association must be unique and match that tuple; a shared head cannot remap an old run to another PR, and the recorded
base must equal live main.
After the assessments match, .github/scripts/quick-v2-metadata-policy-v1.js: rebindForPublication rereads the run
attempt and job topology, verifies the recorded PR association again, and makes the PR plus live main its final API
reads immediately before the status POST. A changed attempt, topology, head, base, or main becomes unknown/failure,
or an incompletely bound result is withheld. The final API reads and the status POST are not atomic because GitHub has
no transaction spanning them; keeping those reads last reduces the remaining observation-only race but cannot make
this context safe for promotion.
quick-v2-metadata-policy-v1.js: getTreeEntry walks trusted base controls: parents must be 040000 trees and files
must be 100644 blobs. Missing, duplicate, truncated, executable, symlink, and gitlink entries fail closed. Candidate
trees are metadata-only and these entries must match the base blobs:
.github/workflows/quick.yml.github/scripts/classify-quick-paths.py.github/scripts/base-gate-policy-v1.js.github/workflows/quick-v2.yml.github/scripts/quick-v2-metadata-policy-v1.js
The trusted base classifier predicts whether substantive quick must run. Workflow-level success with that job skipped
is unknown/failure. Stale head/base, fork, cancellation, changed control, incomplete pagination, ambiguous association,
non-current attempt, or route/job disagreement cannot publish success.
The observation logs these metrics from the independently bound assessment:
route_job_concordance: trusted route versus the complete observed job topology;terminal_binding_coverage: terminal run ID and attempt bound to exact pull/head/base;- zero self-hosted v2 minutes (
v2_self_hosted_minutes: 0); - zero false-success and stale-head publication (
false_success_publications: 0,stale_head_publications: 0).
Unique authority required before promotion¶
In-repository GitHub Actions cannot provide a unique required-status authority.
Promotion requires a separate RigPlane Gatekeeper GitHub App and controller
with all of these properties:
- Its installation app ID is distinct from GitHub Actions app ID
15368. - Its private key and state live outside this repository and Actions secrets; repository workflows cannot request it.
- It alone publishes final
quick-v3andAgent Review Gate v3statuses. - Before dispatch it records PR, head, live
main, run ID, workflow path/SHA, and an external nonce. It accepts only that trusted-default-branch run and rereads head plusmainbefore success. - Same-repository code may use the read-only worker after merge-parent verification. Fork code uses an isolated ephemeral/hosted worker without repository secrets or write token; otherwise Gatekeeper publishes failure.
- Failure, cancellation, incomplete metadata, stale state, ambiguity, and API errors fail or leave status unsatisfied.
A repository ruleset-required workflow controlled from a separate locked repository is an acceptable alternative only if GitHub enforces that exact workflow identity and candidate repository workflows cannot satisfy or replace it. Ordinary context/app-ID branch protection is not that mechanism.
Observation and promotion checklist¶
- Merge stage 1 through existing required contexts without changing protection.
- Observe
*-observeon docs-only and same-repository code/CI, checking routing, binding, mutations, and result count. - Exercise failure, cancellation, base advance, incomplete/capped metadata, and a fork; require no fork worker or stale success.
- Audit Gatekeeper or the enforced external workflow; record its distinct app ID and prove Actions cannot mint status.
- Observe
quick-v3andAgent Review Gate v3on docs, code, failure, cancellation, stale-base, and isolated-fork cases. - Read protection and substitute the observed app ID below; refuse the switch if it is missing or equals
15368:
gatekeeper_app_id='<DEDICATED_GATEKEEPER_APP_ID>'
test -n "$gatekeeper_app_id"
test "$gatekeeper_app_id" != 15368
jq -n --argjson app_id "$gatekeeper_app_id" '{
strict: true,
checks: [
{context: "Agent Review Gate v3", app_id: $app_id},
{context: "quick-v3", app_id: $app_id}
]
}' | gh api --method PATCH \
repos/rigplane/rigplane-core/branches/main/protection/required_status_checks \
--input -
- Read protection back, confirm
strict: true, exact contexts, and the dedicated app ID, then validate a harmless PR. The*-observecontexts remain non-required.
Rollback and retirement ordering¶
Legacy producers must remain enabled while the first Gatekeeper generation is promoted. If Gatekeeper fails, switch protection back to the still-running legacy contexts before disabling or editing any v3 publisher:
Rollback invariant: switch protection back to the still-running legacy contexts before disabling a v3 publisher.
gh api --method PATCH \
repos/rigplane/rigplane-core/branches/main/protection/required_status_checks \
--input - <<'JSON'
{
"strict": true,
"checks": [
{"context": "Agent Review Gate", "app_id": 15368},
{"context": "quick", "app_id": 15368}
]
}
JSON
Read protection back and prove both legacy contexts resolve on a current-base PR. Only then repair or disable v3. This restores availability with the known legacy trust limitation; it is an emergency rollback, not completion of
3136.¶
Stage 2 may mark legacy contexts non-required but must keep their producers running for rollback. Deletion is a later stage and is prohibited until a previous, independently operated Gatekeeper generation is still running and observed as a non-required rollback target. The required-check switch always precedes removal of the generation being retired, so protection can never require a context whose producer has disappeared.