ADR-0042: Count a gate-change acknowledgment only when an admin or maintainer applied it
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-09-27 |
| Review by | 2027-09-27 |
| Schema version | 0.1.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | ci, governance, security |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Review tier | arb |
| Review reason | Narrows who can authorize a change to the surface ADR-0035 already treats as a two-way door with an org-wide blast radius; the rule it amends gates every pull request that touches a workflow, script, or CODEOWNERS file. |
| SLA (days) | 30 |
| Queued at | 2026-09-27T00:00:00Z |
| Relates to | 0035, 0016, 0006 |
| Affects | path:.github/workflows/trusted-gates.yml, path:scripts/check-gate-integrity.ts |
| Source | docs/adr/0042-count-a-gate-change-acknowledgment-only-when-an-admin-or-maintainer-applied-it.md |
Status: accepted. Agent-drafted for @davesheffer; ratified by @mbeacom on 2026-09-27, after #233 merged, with the rename-around-dismissal route (action item 5) deliberately deferred. This record amends ADR-0035; that record’s
gate-change-acknowledgedassertion and Decision item 2 were updated to say so in the same change.
Context
Section titled “Context”ADR-0035
relied on GitHub’s native label permission as the authorization for
gate-change-acknowledged: applying a label “requires triage or write access,”
so an external contributor cannot self-authorize, and the act is recorded in the
timeline against whoever performed it. That sufficiency claim does not hold once
triage access is on the table. Triage exists so a contributor can help manage
issues and pull requests — labeling among them — so granting it for that purpose
also grants the power to acknowledge one’s own gate change, or another
triage-holder’s. The label would still be present, still attributed, and still
insufficient.
The gap surfaced in review of PR #219: @mbeacom proposed reading the actor off
the latest labeled event via the collaborator-permission API and requiring
admin or maintain there, closing self-ack and cross-ack between contributors
alike. @davesheffer’s comment on the same PR had proposed a weaker rule first —
“the PR author’s own acknowledgment does not count” — which stops an author from
labeling their own change but does nothing about a second triage-holder labeling
it for them. #232 tracks closing
that gap before triage is granted to any contributor; today the repository has
no triage-holders and no org custom roles, so this is a precondition rather than
a response to an incident.
Decision
Section titled “Decision”We will count a gate-change-acknowledged label only when the actor who most
recently applied it holds the admin or maintain role at the time the check
runs, superseding “triage or write access” as the sufficient condition
wherever ADR-0035 stated it. Concretely, all of the following must hold:
- The label is currently on the pull request (the live labels API, as today).
- The pull request’s issue-event history (
GET repos/{repo}/issues/{n}/events, paginated) contains at least onelabeled/unlabeledevent for that label, and the latest such event, ordered by timestamp with a numeric-id tie-break, islabeled. A latest event ofunlabeledwhile the label is present is stale or inconsistent history and does not count. Alabeledorunlabeledevent that names no label is ordered with the acknowledgment’s own events: it could be this label’s removal, so if it is the latest, the acknowledgment does not count. It does not fail the job, so a clean pull request stays green. - That latest
labeledevent names a non-null actor with a syntactically valid GitHub login. - The run’s own event must not contradict the history. If the run was
triggered by a
labeledevent for this label, the trigger’s sender must match the history-derived actor (case-insensitively). If it was triggered by anunlabeledevent for this label and the label is present anyway, it was applied again after that removal. If this run’s dismissal step removed the label and it is present anyway, it was applied again while the run was in progress. In each case the acknowledgment is unverified in this run: the history it read may not show the latest application yet, and thelabeledrun that application started is the one that judges it. GET repos/{repo}/collaborators/{actor}/permissionsucceeds, names that same actor, reportsrole_nameof exactlyadminormaintain(no case folding; a custom role name or an empty string is rejected), and the corresponding flag istrue:permissions.adminforadmin,permissions.maintainformaintain.
Anything else does not count, and the check fails closed: a lookup failure, an
unattributable actor, or a role short of admin/maintain all block a gate
change the same way an absent label does. A history that cannot be ordered at
all — an event for this label with no readable id or created_at — fails the
job outright, clean pull requests included. Bots are rejected by construction —
the live probe below shows role_name: "" for github-actions[bot].
Verified live-API shapes (probed against davesheffer/adrkit, 2026-09-27):
{"permission":"admin","role_name":"admin","user":{"login":"davesheffer","permissions":{"admin":true,"maintain":true,"pull":true,"push":true,"triage":true}}}{"permission":"none","role_name":"","user":{"login":"github-actions[bot]","permissions":{"admin":false,"maintain":false,"pull":false,"push":false,"triage":false}}}{"id":31801701021,"event":"labeled","created_at":"2026-09-25T01:03:39Z","actor":{"login":"mbeacom"},"label":{"name":"gate-change-acknowledged"},"performed_via_github_app":null}Other event types on the same endpoint — referenced, cross-referenced,
subscribed, and the rest — carry no label field and are ignored. A token
without push access gets HTTP 403 "Must have push access to view collaborator permission" from the permission endpoint. The workflow’s own GITHUB_TOKEN
(contents: read, pull-requests: write) can read it, and the events
endpoint too — observed on a real pull_request_target run; see Trade-offs.
What we are explicitly not doing
Section titled “What we are explicitly not doing”- Not checking the PR author’s identity separately. An admin or maintainer acknowledging their own pull request still counts. Whoever can merge can already change gates without a label at all, per ADR-0035’s own trade-offs; restricting self-acknowledgment specifically would add a rule that a merge-access holder can route around by asking any other admin or maintainer to label it, at the cost of a deadlock if there is only one.
- Not using
github.event.senderas the source of the actor. It is only present, and only correct, on the event that triggered this run. Every other triggering event —synchronize,opened,edited— has a sender who is not the labeler, which is why rule 4 cross-checks the sender against history rather than substituting for it. - Not trusting API pagination order. The events endpoint returns in the order the API happens to produce; the rule sorts a copy by parsed timestamp with a numeric-id tie-break before taking “latest.”
- Not accepting custom roles.
role_namemust equaladminormaintainexactly. The repository is in a personal namespace with no organization and no custom roles today; if it moves to one, this needs an explicit amendment (see Trade-offs). - Not changing what dismisses.
dismiss-stale-acknowledgmentremoves the label on exactly the events it did before. It now also reports that it did, so rule 4 can tell a label that survived dismissal from one applied after it; this record only narrows who a surviving label counts as coming from. - Not making merge access tamper-proof. Nothing here changes the honest limit ADR-0035 already recorded: whoever can merge can still label.
Options considered
Section titled “Options considered”Option A: Presence only (status quo)
Section titled “Option A: Presence only (status quo)”Pros: already shipped; simplest. Cons: exactly the gap this record closes — sufficient today only because the repository has no triage-holders yet; stops being sufficient the moment one is granted.
Option B: Exclude the PR author only
Section titled “Option B: Exclude the PR author only”The weaker rule from @davesheffer’s comment on #219: the acknowledging actor must not be the pull request’s own author.
Pros: simple; closes self-ack. Cons: does not close cross-ack — any two triage-holders can label each other’s changes, which defeats the purpose of restricting the authorization to a small, trusted set.
Option C: Latest labeler must hold admin or maintain, verified live (chosen)
Section titled “Option C: Latest labeler must hold admin or maintain, verified live (chosen)”Pros: closes self-ack and cross-ack alike, for any set of triage-holders, present or future; ties the authorization to a role independent of who opened the pull request; verified against the live API rather than assumed. Cons: an extra API call and a fail-closed dependency on the collaborator-permission endpoint’s availability to the workflow’s token (observed on a fork dry run, see Trade-offs); more moving parts than Option B.
Option D: Require an approving review from a CODEOWNER instead of a label
Section titled “Option D: Require an approving review from a CODEOWNER instead of a label”Pros: uses a native GitHub review, not a bespoke label protocol.
Cons: docs/repository-trust-operations.md
§2.2 and ADR-0035’s own “What we are explicitly not doing” section already
establish why a required-review rule deadlocks here: GitHub does not let an
author approve their own pull request, and with a sole maintainer who is also
the sole code owner, every self-authored change either deadlocks or is waved
through by the admin bypass. The same failure applies to this narrower use.
Option E: Trust github.event.sender as the actor
Section titled “Option E: Trust github.event.sender as the actor”Pros: no extra API call; no history read.
Cons: wrong on every event type except the exact labeled event for this
label — synchronize, opened, edited, and even a labeled event for an
unrelated label all carry a sender who did not apply this acknowledgment. This
is precisely the substitution rule 4 exists to avoid.
Trade-offs
Section titled “Trade-offs”- Eventual consistency. The issue-events API may lag the label state it
describes; by how much is not measured. Two observations from 2026-09-27,
neither a measurement: a
labeledrun’s history read, within about nine seconds of the label being applied, already showed the application; and an events read issued immediately after a label was deleted omitted the resultingunlabeledevent, which the timeline endpoint returned a moment later and the events endpoint by the next poll, roughly ten to fifteen seconds after the deletion. The second may be the deletion’s asynchronous removal rather than lag. The rules narrow what a lag can do. A lag of one event cannot make a relabel count: if the history shows the removal but not the re-application, rule 2 sees a latestunlabeledand refuses. Rule 4 covers a run whose own event is the removal or the re-application, and a run that dismissed the label itself. What remains is any other run that reads a history lagging at least two events behind a removal and re-application made after that run’s own event: a run started by a title or body edit, by another label being added or removed, or by the maintainer’s own application of this label, whose sender matches the stale history so the cross-check passes. The sequence: a maintainer applies the label; before that run, or a run started by a later title edit, reads the history, someone with triage removes and re-applies it; the history the run reads shows neither, and credits the maintainer. The re-application’s ownlabeledrun judges it correctly, but the runs report on the same head commit, and which result stands is GitHub’s choice among them. How large a lag this needs depends on how closely the relabel can be timed against the run’s read, which the public run log exposes. The residual is accepted and named here rather than claimed away. - Label renames and deletions. Attribution keys on the label name recorded
in each event. Probed on
davesheffer/adrkit, 2026-09-27: an event keeps the name the label had when the event happened. A label applied asadrkit-probeand then renamed toadrkit-probe-renamedstill reportedadrkit-probeon itslabeledevent, while the pull request’s label list reported the new name; the rename itself emitted no event. Deleting the label removed it from the pull request and emitted anunlabeledevent carrying the name at deletion and a fulllabelobject, notnull, so thelabel: nullhandling stays defensive rather than observed. Renaming a label a maintainer applied for another reason togate-change-acknowledgedtherefore inherits nothing: that application is recorded under the old name, the history holds no application of this label, and the acknowledgment is unattributed. Renaming the acknowledgment itself is not inert, and that route stays open. Dismissal removes the label by name. Someone who renames it away before a push therefore keeps it on the pull request through that push’s dismissal, which finds nothing to delete. Renaming it back afterwards emits no event, so the history’s latest event for the name is still the maintainer’s application. A later run that does not dismiss — one started by a title edit or by another label — then credits that application to a head the maintainer never saw. This needs write access, since editing labels is a write permission that triage does not grant, and write is not a role this record lets acknowledge. It also needs an admin’s or maintainer’s earlier acknowledgment on the same pull request and the ability to push its head. The route is recorded here as a residual rather than closed; see Action items. - Token access. Observed on a real
pull_request_targetrun of this workflow as ofa68200bon the contributor’s forkdavesheffer/adrkit, from a same-repository pull request (run 36322666130): withcontents: readandpull-requests: write, the job’sGITHUB_TOKENread bothissues/{n}/eventsandcollaborators/{actor}/permission, and the verdict wasapplied by @davesheffer (admin). The same pull request’s run before labeling (run 36322605737) blocked the gate change, and a clean-path pull request (run 36322610068) stayed green. Not observed: a pull request from a fork into this repository.pull_request_targetissues the base repository’s token with the declared permissions either way, but that is GitHub’s documented behavior, not something these runs show. A 403 there would fail closed: even the owner’s acknowledgment would stop counting. - Custom roles. If the repository ever moves into an organization, custom
roles could grant label-application power without ever reporting
role_nameasadminormaintain. This record does not anticipate that; it needs an explicit amendment if the namespace changes, per ADR-0006. - Overlapping runs. ADR-0035 already accepted that an older run can remove a newly applied acknowledgment and force it to be reapplied, as a false-negative in the fail-closed direction. That residual is unchanged here.
Consequences
Section titled “Consequences”- Easier: distinguishing an acknowledgment a triage-holder gave themselves from one an admin or maintainer gave; auditing exactly who authorized a gate change and under what role, from the timeline alone — though not which head it covered, since a rename leaves no event (see Label renames and deletions).
- Harder: landing a gate-touching pull request when the only accounts able to label it are admins or maintainers. Today that is @mbeacom alone, so no practical acknowledger changes until triage is granted to someone else — this record has no observable effect until then, which is the point: it is a precondition, not a response to an incident.
- How we would know this was wrong: if a legitimate gate change repeatedly stalls because no admin or maintainer is available to label it, forcing a workaround that erodes the acknowledgment’s meaning the way a reflexive label already would under ADR-0035.
- Revisit if: triage or write access is granted to a contributor and the friction above becomes real; or the repository moves to an organization and custom roles need an explicit carve-out.
Action items
Section titled “Action items”- Ratify or reject. Ratified by @mbeacom, 2026-09-27.
- Prove the workflow token can read
collaborators/{actor}/permissionon a realpull_request_targetrun before merge. Done on the fork, 2026-09-27 — see Token access above. - Probe what
issues/{n}/eventsreports for a renamed and a deleted label before ratification. Done 2026-09-27: events keep the name at event time. Renaming another label to this one inherits nothing, but renaming this label away and back around a push survives dismissal — see Label renames and deletions above. - Observe both the accept and the insufficient-role paths on a real pull
request after merge, per ADR-0016. Not yet: no acknowledgment has been
evaluated under this rule here. #237’s passing
gate-integrityrun (36365739410, 01:22 UTC) predates the 01:33 merge of #233 and ran the old presence-only rule. The accept path has been observed only on the fork. - Decide whether to close the rename-around-dismissal route before write access is granted to anyone else. Deferred by @mbeacom at ratification (2026-09-27). The route is reachable today only by the existing maintainer, @mbeacom, who already holds write access and could acknowledge directly; nobody else holds write. Revisit — and close it or accept it explicitly — before write access is granted to any other actor. One candidate: dismissal also removes every label whose latest history event under its current name is not an application — the test rule 2 applies to this label, which catches one renamed since it was applied — at the cost of dropping legitimately renamed labels on each push, and of removing a label whose application the events API has not yet reported.