Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ Firstmate's skills live in two separate places with different audiences:
- [docs/codex-app-backend.md](docs/codex-app-backend.md) - Codex App backend boundary, evidence, and rollout contract.
- [docs/codex-busy-detection.md](docs/codex-busy-detection.md) - Codex 0.145.0 busy-row evidence behind the watcher liveness backstop.
- [docs/gitlab-merge-watch.md](docs/gitlab-merge-watch.md) - how the merge watch follows a GitLab merge request on any instance, and the evidence behind it.
- [docs/merge-gate-audit.md](docs/merge-gate-audit.md) - how to audit GitHub merge gates across rulesets and classic branch protection, including the current heavyliftrental fleet gate map.
- [docs/turnend-guard.md](docs/turnend-guard.md) - the primary session's structural "no turn ends blind" backstop: verified per-harness hook mechanisms, scoping, loop safety, and fail-open tradeoffs.
- [docs/context-reset.md](docs/context-reset.md) - the stow-then-clear context ceiling: what the watcher measures, when it resets, asks, blocks, or reports itself unenforced, and every refusal the reset tool makes.
- [docs/supervision-protocols/](docs/supervision-protocols/) - rendered primary-harness watcher protocols for Claude, Codex, OpenCode, Pi, Grok, and unknown harness fallback.
Expand Down
1 change: 1 addition & 0 deletions docs/admiralty-fleet-repo.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ A fleet front page, when one is wanted, is a new fleet-owned file rather than an
This is the one deliverable of the build that did not land, and it is a captain decision rather than something to work around.

GitHub refuses branch protection on a private repository on this account.
For the general audit rule that checks both APIs before calling a repository gated or ungated, see [merge-gate-audit.md](merge-gate-audit.md).
Both APIs return the same thing:

```
Expand Down
114 changes: 114 additions & 0 deletions docs/merge-gate-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Auditing merge gates: query both mechanisms, then prove the gate

A GitHub required-status-check gate that blocks merges can live in either of two independent mechanisms.
An audit that queries one mechanism gets a confidently wrong answer about the other, and that wrong answer looks exactly like a clean result.
This doc records where a fleet's gates live, which endpoints an audit must query, and how to prove a gate actually blocks.

## The two mechanisms

- **Repository rulesets** at `GET /repos/<owner>/<repo>/rulesets` (then `GET .../rulesets/<id>` for the rules).
A ruleset gate is a `required_status_checks` rule naming one or more contexts.
- **Classic branch protection** at `GET /repos/<owner>/<repo>/branches/<branch>/protection`.
A classic gate is the `required_status_checks` block naming contexts.

Both genuinely block merges.
Neither is a defect.
GitHub also keeps a separate control, the push restriction (`restrictions`), only in classic branch protection, with no ruleset equivalent used in this fleet, so classic protection can be present purely to restrict who may push to the branch, carrying no required check at all.

## The both-endpoints rule

An audit MUST query both endpoints and combine the answers.
The reason is a measurement trap, not a preference.

- Querying `/rulesets` on a repo gated by classic branch protection returns `[]`, which reads exactly like "no gate" on a repo whose gate is active and proven blocking.
- Querying `/branches/main/protection` on a repo gated by a ruleset returns `Branch not protected (HTTP 404)`, which also reads like "no gate".

Treat an empty `/rulesets` array as "check classic protection", never as "unprotected".
Treat a `404` from `/branches/main/protection` as "check rulesets", never as "unprotected".

A ruleset being `enforcement: active` is not by itself a gate.
Confirm the ruleset actually contains a `required_status_checks` rule naming a context.
An active ruleset with no such rule requires nothing.

## Reading the check result

The GitHub Statuses API is the wrong endpoint for GitHub Actions results.
`GET /repos/<owner>/<repo>/commits/<ref>/status` returns `total_count: 0` for Actions checks, so an audit reading Statuses concludes "no checks ran" for a repo whose checks all ran.
Actions checks report through `GET /repos/<owner>/<repo>/commits/<ref>/check-runs`, which is where `conclusion: failure` lives.

When reading a pull request's own state, note that `GET /repos/<owner>/<repo>/pulls/<n>` can return a stale `head.sha` and stale check state for a short window after a push.
Poll `check-runs` against the head SHA you pushed, read from `git rev-parse HEAD` or `git ls-remote`, rather than trusting the pull request object's cached `head.sha`.

## The bypass dimension

A gate that the person most likely to be in a hurry can step around is worth knowing about explicitly.
Query the bypass posture of every gate, on whichever mechanism holds it.

- Ruleset: read `bypass_actors` and `current_user_can_bypass` on the ruleset.
`bypass_actors: []` with `current_user_can_bypass: never` means no one, including an administrator, can merge past the required check.
- Classic branch protection: read `enforce_admins`.
`enforce_admins.enabled: false` means an administrator can step around every protection on the branch, including the required check and the push restriction.

The two mechanisms differ here in a way that matters.
A ruleset with no bypass actors cannot be walked through by an administrator.
Classic branch protection with `enforce_admins: false` can.
So moving a required check from classic protection into a no-bypass ruleset closes the administrator bypass on that check as a side effect, and the change should be disclosed as such rather than treated as invisible.

## Proving a gate

A gate is not proven by reading its configuration.
A configuration that reads correct is exactly the evidence class that hides a gate which no longer blocks.
Prove a required-check gate by breaking a real invariant and watching the gate refuse the merge, then restoring it and watching the gate clear.

1. On a throwaway branch, change a source file so a genuine regression test fails, not a fabricated `assert False`.
2. Open a pull request and poll `check-runs` on its head until the required check reaches `conclusion: failure`.
3. Confirm the merge is refused by reading `mergeable_state: blocked` on the pull request.
Do not call the merge endpoint to "test" this; a blocked `mergeable_state` with a red required check is the refusal, and calling merge risks landing it.
4. Restore the invariant, push, and poll `check-runs` on the new head until `conclusion: success` and `mergeable_state: clean`.
5. Close the pull request without merging, then delete its remote branch explicitly.
`gh pr close --delete-branch` (and the `gh-axi` wrapper) accepts the flag but does not delete the remote branch, so run `git push origin --delete <branch>` and confirm `GET /repos/<owner>/<repo>/branches/<branch>` returns `404`.

## Current heavyliftrental fleet state (2026-08-05)

Thirteen fleet repositories carry a required-check merge gate: `hlr-certsync`, `hlr-vat-steward`, `hlr-adsbot`, `hlr-einkauf`, `hlr-engineering-vault`, `hlr-knowledge`, `hlr-infra`, `hlr-librechat`, `hlr-tank-cad`, `hlr-reporting`, `hlr-dms`, `hlr-pim`, and `hlr-research`.

The chosen fleet standard for the required-check gate is the repository ruleset with no bypass actors.
The reasons are that twelve of the thirteen were already there, that rulesets are the newer mechanism, that the admiralty branch-protection doctrine already prescribes a ruleset with no bypass actors (see [admiralty-fleet-repo.md](admiralty-fleet-repo.md)), and that a no-bypass ruleset closes the administrator walk-through that classic protection leaves open.

`hlr-research` was the one repository on the classic path.
The reason was a rollout artifact, not a property of the repository: during the 2026-08-04 rollout a ruleset could not be activated until its workflow had already run on `main` and produced its check, or the repository would lock (the `hlr-reporting` bootstrap deadlock), and `hlr-research` correctly refused to activate until its `pr-tests.yml` existed on `main`.
That workflow now exists on `main` and produces the `Research regression tests` check on every pull request, so the blocker is gone and the classic path was no longer required.

On 2026-08-05 `hlr-research` was converted:
its required-check gate now lives in ruleset `Research required PR tests` (id `20456354`), with `strict_required_status_checks_policy: true`, context `Research regression tests`, `bypass_actors: []`, `current_user_can_bypass: never`, matching the other twelve.
Its classic branch protection was reduced to the push restriction only (`restrictions.users: [Freudator86]`, no required check), matching the ten peers that keep a push restriction.
The gate was re-proven after conversion: a broken WLL-classification invariant drove `Research regression tests` to `conclusion: failure` and the pull request to `mergeable_state: blocked`; restoring the invariant drove the check to `conclusion: success` and the pull request to `mergeable_state: clean`; the throwaway pull request was closed unmerged and its branch deleted and confirmed `404`.

After this change, the required-check gate is uniformly on rulesets for all thirteen, so a required-check-only audit has a single home today.
The both-endpoints rule still stands, for two durable reasons:
a push restriction lives only in classic branch protection and exists on ten of the thirteen repositories (`hlr-certsync`, `hlr-engineering-vault`, and `hlr-knowledge` have no classic protection at all), and a repository added before its ruleset is safe to activate will sit on classic protection exactly as `hlr-research` and `hlr-reporting` did during rollout.

### Where each control lives, and which endpoint answers for it

| Control | Mechanism | Endpoint to query |
| --- | --- | --- |
| Required-check merge gate (all 13) | Repository ruleset | `/repos/<r>/rulesets` then `/rulesets/<id>` |
| Push restriction (10 of 13) | Classic branch protection | `/repos/<r>/branches/main/protection` |
| Actions check result on a commit | Checks API | `/repos/<r>/commits/<ref>/check-runs` |

### Bypass picture

- All thirteen required-check ruleset gates: `bypass_actors: []`, `current_user_can_bypass: never`.
No one, including the administrator, can merge past a red required check.
- The ten classic push restrictions: `enforce_admins.enabled: false`.
An administrator can push directly to `main`, stepping around the push restriction.
In this single-maintainer fleet the sole administrator (`Freudator86`) is also the sole user the push restriction allows, so the bypass grants that user nothing extra today; it would matter if another administrator were added.
- Before conversion, `hlr-research`'s required check sat in classic protection with `enforce_admins: false`, so it was administrator-bypassable, unlike the other twelve.
The conversion to a no-bypass ruleset closed that one hole and is the only bypass state this task changed.

Whether to set `enforce_admins: true` on the ten push restrictions is a separate decision, deliberately not taken here, and is recorded so it can be decided rather than discovered later.

## Maintaining this file

Keep this file to the durable method (query both mechanisms, read `check-runs`, prove the gate) plus a dated snapshot of the fleet's current gate map.
When the fleet's gate configuration changes, update the dated snapshot rather than appending a second one, and re-run the both-endpoints survey before trusting any claim that a repository is or is not gated.
Loading