- Status: Accepted
- Date: 2026-07-16
- Deciders: thulasi.ram (architect)
- Refines: 0037 (what
allowed_refsis evaluated against), 0043 (dispatch carries a symbolic ref + a resolved SHA), 0010 (read-at-ref)
Context
Section titled “Context”An Environment’s allowed_refs protection rule (0037)
branch-scopes a deploy — e.g. allowed_refs: ["refs/heads/main"] means “only
main may deploy to prod.” Its patterns are symbolic ref globs
(refs/heads/main, refs/heads/*, refs/tags/*), matched by glob_match.
But admission evaluated ref_allowed against config_ref(event) — the
immutable commit the config is read and the Run is pinned at (GitHub’s
after for a push, head for a PR, the resolved commit for a dispatch). A
40-char SHA never matches a branch glob, so the rule was silently inert: an
empty allowed_refs admitted everything; a non-empty one rejected
everything — including a legitimate main deploy. The guardrail did the
opposite of its job. A second enforcement point had the same defect:
gate-approval re-runs ProtectionRules::admits(&DeployContext.git_ref, …) on
release, and git_ref was likewise stored from config_ref (a SHA), so an
approver-gated protected deploy would never release.
The root cause is one value serving two incompatible purposes: where to read the immutable config (must be a SHA — reproducible, tamper-proof mid-run) and which branch/tag the run is “on” (must be symbolic — that is what protection rules, deployment history, and humans reason about).
Decision
Section titled “Decision”Split the two ref meanings explicitly.
- Read/pin ref —
config_ref(event), the immutable commit. Unchanged; used to fetch.scarab/**and pin the self-describing Run (0022). - Protection ref — a new
Event::protection_ref() -> Option<String>, the symbolic branch/tag ref used for allallowed_refsmatching (both the creation-time check and the gate-approval re-check, viaDeployContext.git_ref):Push→ itsrefs/heads/…ref;Tag/Release→refs/tags/<tag>;PullRequest→refs/pull/<n>/head— deliberately not a branch ref, so a PR is denied a branch-scoped Environment unless the env explicitly opts PRs in viarefs/pull/*(the intended fail-safe);Manual/Api→ the symbolic dispatch ref (see below);Comment/Cron/Upstream→None.
- Fail-closed on
None: an event with no symbolic ref is admitted only whenallowed_refsis empty (unrestricted); any non-empty rule denies it. - Manual/Api events carry both, mirroring
Push { r#ref, after }:{ r#ref: <symbolic>, sha: <resolved commit> }. Dispatch (0043) resolves the user ref → SHA for reading/pinning, and canonicalizes the user ref for protection: arefs/…ref or a 40-char lowercase-hex SHA is kept verbatim (a raw SHA stays opaque so it can never match a branch glob — a bare commit is correctly denied a branch-scoped env), and any other bare name is treated as a branch (main→refs/heads/main).
Consequences
Section titled “Consequences”allowed_refsfinally gates by branch — the headline fix — on the push, dispatch, and PR paths, at both enforcement points. Regression tests assert afeature-branch run is denied arefs/heads/main-scoped env and amainrun is admitted while still pinning the resolved SHA; these tests fail against the pre-fix code.- Deployment history records the symbolic ref, not a SHA — what an auditor expects.
- PRs are fail-safe against protected environments by default.
- The read/pin path is untouched, so reproducibility and tamper-resistance are preserved.
Alternatives considered
Section titled “Alternatives considered”- Keep matching
config_refand requireallowed_refsto list SHAs — absurd: SHAs are per-commit, so the rule could never express “themainbranch,” which is the entire point of branch protection. - Normalize inside
ref_allowed(strip to a SHA, or fuzzy-match) — pushes ref semantics into the purescarab-projectsmatcher and guesses; the ref meaning belongs to the event, which is whereprotection_reflives. - A single canonical ref on every event — collapses the two genuinely different needs (immutable read point vs symbolic branch) that this ADR exists to separate.