From 1cc72cdc80c9c60b2a857df7d8753a7dfb7e87fb Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:13:21 +0100 Subject: [PATCH 01/13] feat(rulesets): base protection floor applier, branch + tag (#787) Closes the last structural gap in "modern rulesets only": every repo in both estates reaches a base-level protection, expressed purely as a ruleset. No classic branch protection is created, read or migrated -- it is already gone estate-wide (measured 0 of 283; the classic TAG endpoint is retired and 404s). The floor is deletion + non_fast_forward, zero bypass actors, on ~DEFAULT_BRANCH for branches and ~ALL for tags. Three siblings now exist and do not overlap: apply-branch-gates.sh fills required_status_checks on an EXISTING ruleset apply-tag-ruleset-canon.sh reconciles the tag canon apply-protection-floor.sh CREATES a floor where no ruleset exists at all The gap this fills is named in config/rulesets/README.adoc: the gate-filler never creates a ruleset, because creating protection where none exists is a policy act rather than a gate-fill. That is now a separate, explicit script. D50 is enforced structurally, not incidentally. git-remote-gcrypt force-pushes by design on every sync, so writing non_fast_forward to a gcrypt vault does not harden it -- it silently stops the hourly backup at the next timer fire. The two members are an EXPLICIT LIST in config/rulesets/gcrypt-vault-class.txt, never a name regex: reasonably-good-token-vault and befunge93-vault-cracker both match /vault/ and both genuinely need the floor. A missing class file is a REFUSAL, because an absent exclusion list is indistinguishable from an empty one. Safety properties, each covered by an assertion: - APPLY=0 by default; writes require --apply - the D50 check runs BEFORE any API read, so a vault is never even probed - shape identity includes bypass_actors, so a bypassed twin is not "converged" - an absent .source_type REFUSES rather than defaulting to the writable arm (PUT to an org-inherited ruleset 404s; the discriminator is the only guard) - 403/422 on the rulesets list is PLAN-EXCLUDED, establishing the private denominator structurally instead of guessing it - every write is verified by an INDEPENDENT re-read: a ruleset PUT has returned 200 with an empty body and not applied - an empty repo list is refused, so a clean sweep is never reported over nothing The suite kills four mutants. Two of them only became honest after the harness itself was fixed: the mutant had been written to a temp dir, so REPO_ROOT resolved there and it died on a missing canon file before any guard ran, which bash -n cannot catch. The mutant now lives in the real scripts/ dir, the sed is asserted to have changed something, and a mutant that produces no report is treated as a meaningless red rather than a kill. Refs hyperpolymath/standards#787, hyperpolymath/standards#956 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- config/rulesets/branch-floor.json | 16 ++ config/rulesets/gcrypt-vault-class.txt | 17 ++ config/rulesets/tag-floor.json | 16 ++ scripts/apply-protection-floor.sh | 263 +++++++++++++++++++++++++ scripts/tests/protection-floor-test.sh | 248 +++++++++++++++++++++++ 5 files changed, 560 insertions(+) create mode 100644 config/rulesets/branch-floor.json create mode 100644 config/rulesets/gcrypt-vault-class.txt create mode 100644 config/rulesets/tag-floor.json create mode 100755 scripts/apply-protection-floor.sh create mode 100755 scripts/tests/protection-floor-test.sh diff --git a/config/rulesets/branch-floor.json b/config/rulesets/branch-floor.json new file mode 100644 index 000000000..465908ac8 --- /dev/null +++ b/config/rulesets/branch-floor.json @@ -0,0 +1,16 @@ +{ + "name": "Branch-Floor", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" } + ] +} diff --git a/config/rulesets/gcrypt-vault-class.txt b/config/rulesets/gcrypt-vault-class.txt new file mode 100644 index 000000000..ceb37657d --- /dev/null +++ b/config/rulesets/gcrypt-vault-class.txt @@ -0,0 +1,17 @@ +# The gcrypt-vault exclusion class -- owner ruling D50, 2026-09-14T20:58Z. +# +# A git-remote-gcrypt vault FORCE-PUSHES ON EVERY SYNC: the remote ref is a rolling +# pointer at the newest encrypted pack and is never a fast-forward of the previous one. +# Applying `non_fast_forward` to a member does not harden it -- it silently stops the +# hourly backup at the next timer fire. +# +# Members carry `Gcrypt-Vault-Guard` instead: `deletion` only, on `~ALL` (a gcrypt vault +# has exactly ONE remote ref and it is `master`, so a `main`-scoped rule is a fake gate). +# +# โš  MEMBERSHIP IS THIS LIST, NEVER A NAME MATCH. `reasonably-good-token-vault` and +# `befunge93-vault-cracker` both match /vault/ and are NOT members -- they are ordinary +# source repos that genuinely need the floor. +# +# Class record: dev-notes/estate-management/gcrypt-vault-protection-class-2026-09-14/ +hyperpolymath/dev-notes-vault +hyperpolymath/memory-vault diff --git a/config/rulesets/tag-floor.json b/config/rulesets/tag-floor.json new file mode 100644 index 000000000..5b2029dd6 --- /dev/null +++ b/config/rulesets/tag-floor.json @@ -0,0 +1,16 @@ +{ + "name": "Tag-Floor", + "target": "tag", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": ["~ALL"], + "exclude": [] + } + }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" } + ] +} diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh new file mode 100755 index 000000000..62f3eba88 --- /dev/null +++ b/scripts/apply-protection-floor.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# apply-protection-floor.sh -- establish the BASE PROTECTION FLOOR on a repo that has none. +# +# The floor is owner ruling D94: `deletion` + `non_fast_forward`, and nothing else. +# * branch target -> `~DEFAULT_BRANCH` (config/rulesets/branch-floor.json) +# * tag target -> `~ALL` (config/rulesets/tag-floor.json) +# Bypass is the empty list, per D96. +# +# WHY A THIRD APPLIER, BESIDE apply-branch-gates.sh AND apply-tag-ruleset-canon.sh +# apply-branch-gates.sh says so itself: "It never CREATES a ruleset. A repo with no +# active branch ruleset is reported NORULESET. Creating branch protection where none +# exists is a policy act, not a gate-fill." That policy act is now ruled (D94-D96), so +# it gets its own script and the gate-filler stays a gate-filler. +# +# WHY THE FLOOR IS EXACTLY TWO RULES +# It needs no per-repo derivation and no CI check to be satisfiable, so it can never be +# a vacuous gate and it cannot block a single PR. It stops precisely two irreversible +# accidents: deleting the default branch, and force-pushing over it. +# +# WHAT IT DELIBERATELY DOES NOT DO +# * It never EDITS an existing ruleset. Rulesets are additive (D95); the floor is POSTed +# alongside. A PUT would replace the whole object, and that is how an applier silently +# revives what a human switched off (the defect repaired in #1030). +# * It never re-enables a disabled ruleset. Disabled is a decision, not drift. +# * It never writes an ORG-INHERITED ruleset. `PUT` to a repo path for an org ruleset +# 404s; the discriminator is `.source_type`, and an ABSENT discriminator REFUSES +# rather than defaulting to the writable arm. +# * It never touches a member of the gcrypt-vault class. See below -- this one is not a +# nicety, it is the difference between a hardened repo and a dead backup. +# +# ๐Ÿšจ THE GCRYPT-VAULT EXCLUSION (owner ruling D50) +# git-remote-gcrypt FORCE-PUSHES ON EVERY SYNC. `non_fast_forward` on a vault does not +# harden it; it stops the hourly backup, silently, at the next timer fire. Vaults carry +# a deliberate `deletion`-only guard, which means naive floor logic -- "has deletion, +# lacks non_fast_forward, therefore complete the floor" -- writes exactly the fatal rule. +# Membership is the EXPLICIT LIST in config/rulesets/gcrypt-vault-class.txt and NEVER a +# name match: two estate repos match /vault/ and are not members. +# A missing class file is a REFUSAL, not an empty exclusion set. +# +# USAGE +# scripts/apply-protection-floor.sh --repos # report only (default) +# scripts/apply-protection-floor.sh --repos --apply # actually create +# scripts/apply-protection-floor.sh --repos --target tag +# Optional: --floor-even-if-covered also floor repos whose cover comes from a RICHER +# ruleset (see COVERED-BY-RICHER below). Off by +# default: that is a policy call, not a gap-fill. +# +# STATES (TSV: repo state detail) +# CONVERGED the exact floor already exists -- nothing to do +# COVERED-BY-RICHER both rules are in force, but from a richer ruleset. NOT the same +# as converged: that cover vanishes the moment the richer ruleset is +# disabled, which is how 230 repos lost protection on 2026-09-22. +# WOULD-CREATE report mode; --apply would POST the floor here +# CREATED POSTed and verified in force +# WROTE-UNVERIFIED POSTed, but the verifying read did not come back. Never assume. +# EXCLUDED-D50 gcrypt vault; no write, ever +# ARCHIVED archived repo; ruleset POST 403s. Skipped, not failed. +# PLAN-EXCLUDED 403/422 from the rulesets endpoint (private repo / plan limit). +# Counted as neither covered nor failed -- this is the honest +# denominator for "repos this tooling cannot reach". +# ORG-INHERITED covered by an org ruleset; cure once at the org, never per repo +# AMBIGUOUS more than one active repo-level floor-shaped ruleset. Fail closed. +# REFUSED a ruleset carried no `.source_type`. Fail closed. +# UNKNOWN a read was throttled or errored. SKIPPED, never recorded as clean. +# +# A THROTTLED READ IS SKIPPED, NEVER RECORDED. A junk row is indistinguishable from an +# honest one, and resume logic keyed on "repo already present" excludes it forever. +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONF="$REPO_ROOT/config/rulesets" +VAULT_CLASS="$CONF/gcrypt-vault-class.txt" + +APPLY=0 +TARGET="branch" +REPOS_FILE="" +FLOOR_EVEN_IF_COVERED=0 + +TMPDIR_ERR="$(mktemp -t protfloor-err.XXXXXX)" +cleanup() { rm -f "$TMPDIR_ERR"; } +trap cleanup EXIT + +die() { printf 'FATAL: %s\n' "$*" >&2; exit 2; } +report() { printf '%s\t%s\t%s\n' "$1" "$2" "${3:-}"; } + +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --target) TARGET="${2:-}"; shift ;; + --repos) REPOS_FILE="${2:-}"; shift ;; + --floor-even-if-covered) FLOOR_EVEN_IF_COVERED=1 ;; + -h|--help) sed -n '2,60p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac + shift +done + +case "$TARGET" in + branch) CANON="$CONF/branch-floor.json"; WANT_INCLUDE='["~DEFAULT_BRANCH"]' ;; + tag) CANON="$CONF/tag-floor.json"; WANT_INCLUDE='["~ALL"]' ;; + *) die "--target must be 'branch' or 'tag', got '$TARGET'" ;; +esac + +[ -r "$CANON" ] || die "canon file missing: $CANON" + +# FAIL CLOSED. A missing class file must never read as "no vaults to protect". +[ -r "$VAULT_CLASS" ] || die "gcrypt-vault class file missing: $VAULT_CLASS -- refusing to run, because an absent exclusion list is indistinguishable from an empty one and D50 members would be written" + +[ -n "$REPOS_FILE" ] || die "--repos is required (one owner/repo per line)" +[ -r "$REPOS_FILE" ] || die "repo list not readable: $REPOS_FILE" + +# The floor's rule types, derived FROM THE CANON -- never typed a second time here, so the +# script and the file can never disagree. +FLOOR_TYPES="$(jq -r '[.rules[].type] | sort | join(",")' "$CANON")" +[ -n "$FLOOR_TYPES" ] || die "canon $CANON declares no rules" + +# Body actually POSTed. `name` is kept (a POST creates, so the name is ours to set). +CANON_BODY="$(jq -c . "$CANON")" + +VAULTS="$(command grep -vE '^[[:space:]]*(#|$)' "$VAULT_CLASS" | tr -d ' \t')" +[ -n "$VAULTS" ] || die "gcrypt-vault class file lists no members -- refusing; D50 names two" + +TARGETS="$(command grep -vE '^[[:space:]]*(#|$)' "$REPOS_FILE" | tr -d ' \t' | sort -u)" +[ -n "$TARGETS" ] || die "refusing to report a clean sweep over nothing: $REPOS_FILE yielded no repos" + +is_vault() { + printf '%s\n' "$VAULTS" | command grep -qxF "$1" +} + +# --------------------------------------------------------------------------- +printf 'repo\tstate\tdetail\n' + +printf '%s\n' "$TARGETS" | while IFS= read -r repo; do + [ -n "$repo" ] || continue + + # 1. D50 FIRST, before any read. A vault must not even be a candidate. + if is_vault "$repo"; then + report "$repo" "EXCLUDED-D50" "gcrypt vault: force-pushes every sync; non_fast_forward would stop the backup" + continue + fi + + # 2. Archived repos 403 on a ruleset write while every GET succeeds. + meta="$(gh api "repos/$repo" 2>/dev/null)" || { report "$repo" "UNKNOWN" "repos/$repo read failed"; continue; } + [ -n "$meta" ] || { report "$repo" "UNKNOWN" "repos/$repo returned empty"; continue; } + if [ "$(printf '%s' "$meta" | jq -r '.archived')" = "true" ]; then + report "$repo" "ARCHIVED" "ruleset POST 403s on an archived repo; unarchive/write/re-archive is a separate, explicit act" + continue + fi + default_branch="$(printf '%s' "$meta" | jq -r '.default_branch // empty')" + + # 3. List rulesets. 403/422 here is the private-repo / plan-limit arm. + if ! listing="$(gh api "repos/$repo/rulesets" 2>"$TMPDIR_ERR")"; then + err="$(cat "$TMPDIR_ERR" 2>/dev/null)" + case "$err" in + *403*|*422*|*"upgrade"*) report "$repo" "PLAN-EXCLUDED" "rulesets endpoint refused: ${err%%$'\n'*}" ;; + *) report "$repo" "UNKNOWN" "rulesets list failed: ${err%%$'\n'*}" ;; + esac + continue + fi + [ -n "$listing" ] || { report "$repo" "UNKNOWN" "rulesets list returned empty"; continue; } + + # 4. An entry with no `.source_type` is a REFUSAL, not a default to the writable arm. + if printf '%s' "$listing" | jq -e 'any(.[]?; has("source_type") | not)' >/dev/null 2>&1; then + report "$repo" "REFUSED" "a ruleset carried no .source_type; cannot tell repo-level from org-inherited, failing closed" + continue + fi + + org_n="$(printf '%s' "$listing" | jq "[.[]? | select(.source_type==\"Organization\" and .target==\"$TARGET\" and .enforcement==\"active\")] | length")" + repo_ids="$(printf '%s' "$listing" | jq -r ".[]? | select(.source_type==\"Repository\" and .target==\"$TARGET\" and .enforcement==\"active\") | .id")" + + # 5. Walk the active repo-level rulesets of this target and classify. + exact_n=0; exact_ids=""; union="" + if [ -n "$repo_ids" ]; then + while IFS= read -r rid; do + [ -n "$rid" ] || continue + body="$(gh api "repos/$repo/rulesets/$rid" 2>/dev/null)" || { body=""; } + if [ -z "$body" ]; then + exact_n=-1 # sentinel: a read we could not complete + break + fi + types="$(printf '%s' "$body" | jq -r '[.rules[].type] | sort | join(",")')" + inc="$(printf '%s' "$body" | jq -c '.conditions.ref_name.include')" + byp="$(printf '%s' "$body" | jq -c '[.bypass_actors[]?] | length')" + union="$union,$types" + if [ "$types" = "$FLOOR_TYPES" ] && [ "$inc" = "$WANT_INCLUDE" ] && [ "$byp" = "0" ]; then + exact_n=$((exact_n + 1)); exact_ids="$exact_ids $rid" + fi + done <"$TMPDIR_ERR")"; then + report "$repo" "UNKNOWN" "POST failed: $(head -1 "$TMPDIR_ERR" 2>/dev/null)" + continue + fi + new_id="$(printf '%s' "$created" | jq -r '.id // empty')" + + # 8. VERIFY BY AN INDEPENDENT READ. A ruleset write has returned 200 with an empty body + # and not applied -- never trust the write's own response. + if [ "$TARGET" = "branch" ] && [ -n "$default_branch" ]; then + eff="$(gh api "repos/$repo/rules/branches/$default_branch" 2>/dev/null)" || eff="" + if [ -z "$eff" ]; then + report "$repo" "WROTE-UNVERIFIED" "POSTed id=$new_id; effective-rules read did not return" + continue + fi + missing="" + printf '%s\n' "$FLOOR_TYPES" | tr ',' '\n' | while IFS= read -r t; do + [ -n "$t" ] || continue + printf '%s' "$eff" | jq -e --arg t "$t" 'any(.[]?; .type==$t)' >/dev/null 2>&1 || exit 7 + done || missing="yes" + if [ -n "$missing" ]; then + report "$repo" "WROTE-UNVERIFIED" "POSTed id=$new_id but the effective rules on $default_branch do not show both floor types" + continue + fi + report "$repo" "CREATED" "id=$new_id; verified effective on $default_branch" + else + back="$(gh api "repos/$repo/rulesets/$new_id" 2>/dev/null)" || back="" + got="$(printf '%s' "$back" | jq -r '[.rules[].type] | sort | join(",")' 2>/dev/null)" + if [ "$got" = "$FLOOR_TYPES" ]; then + report "$repo" "CREATED" "id=$new_id; verified by re-read" + else + report "$repo" "WROTE-UNVERIFIED" "POSTed id=$new_id; re-read returned '${got:-}'" + fi + fi +done diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh new file mode 100755 index 000000000..b59380aa3 --- /dev/null +++ b/scripts/tests/protection-floor-test.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Regression suite for scripts/apply-protection-floor.sh +# +# House conventions, shared with scripts/tests/branch-gates-apply-test.sh: +# * a `gh` shim maps an API path to a fixture by KEY=$(tr '/?&=' '____') +# * A MISSING FIXTURE IS A FREE ASSERTION that the path is never queried: the shim +# exits 1, so any code reaching for an unplanned endpoint fails loudly. +# * every write is appended to $GH_FIX/PUTS.log, so "wrote nothing" is checkable. +# +# The suite ends by KILLING FOUR MUTANTS. A green suite against the real script proves +# only that it agrees with itself; each mutant reintroduces one specific defect and the +# suite must go red for exactly the right reason. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +SUT="$ROOT/scripts/apply-protection-floor.sh" +[ -r "$SUT" ] || { echo "FATAL: script under test missing: $SUT"; exit 2; } + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } + +WORK="$(mktemp -d -t protfloor-test.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT +BIN="$WORK/bin"; FIX="$WORK/fix"; mkdir -p "$BIN" "$FIX" + +cat > "$BIN/gh" <<'SHIM' +#!/usr/bin/env bash +# fixture shim: repos/x/y -> $GH_FIX/repos_x_y +method="GET"; path=""; stdin_body="" +while [ $# -gt 0 ]; do + case "$1" in + api) ;; + --method) method="$2"; shift ;; + --input) stdin_body="$(cat)"; shift ;; + -*) ;; + *) [ -z "$path" ] && path="$1" ;; + esac + shift +done +KEY="$(printf '%s' "$path" | tr '/?&=' '____')" +if [ "$method" != "GET" ]; then + printf '%s\t%s\t%s\n' "$method" "$path" "$stdin_body" >> "$GH_FIX/PUTS.log" + F="$GH_FIX/${method}_${KEY}" + [ -r "$F" ] || { echo "no fixture for $method $path" >&2; exit 1; } + cat "$F"; exit 0 +fi +F="$GH_FIX/$KEY" +[ -r "$F" ] || { echo "no fixture for $path" >&2; exit 1; } +if [ -r "$F.rc" ]; then cat "$F" >&2; exit "$(cat "$F.rc")"; fi +cat "$F" +SHIM +chmod 755 "$BIN/gh" + +run() { GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$1" "${@:2}" 2>/dev/null; } +state() { printf '%s\n' "$1" | awk -F'\t' -v r="$2" '$1==r{print $2}'; } +reset_fix() { rm -rf "$FIX"; mkdir -p "$FIX"; : > "$FIX/PUTS.log"; } + +mkrepo() { # name default archived + printf '{"archived":%s,"default_branch":"%s"}\n' "$3" "$2" > "$FIX/repos_$(printf '%s' "$1" | tr '/' '_')" +} + +REPOS="$WORK/repos.txt" +cat > "$REPOS" <<'EOF' +hyperpolymath/memory-vault +hyperpolymath/plain-repo +hyperpolymath/converged-repo +hyperpolymath/richer-repo +hyperpolymath/bypassed-twin +hyperpolymath/archived-repo +hyperpolymath/private-repo +hyperpolymath/nosourcetype-repo +EOF + +build_fixtures() { # $1 = "with-vault" to give the vault a complete, writable fixture set + reset_fix + # By default memory-vault gets NO fixtures at all. The D50 guard must return before any + # read, so every missing fixture here asserts that nothing was queried. + # With "with-vault" the vault is made fully writable, so a mutant that removes the guard + # produces a REAL POST rather than dying on a missing fixture -- otherwise the mutant's + # red would measure the shim, not the guard. + if [ "${1:-}" = "with-vault" ]; then + mkrepo hyperpolymath/memory-vault main false + echo '[]' > "$FIX/repos_hyperpolymath_memory-vault_rulesets" + echo '{"id":9099}' > "$FIX/POST_repos_hyperpolymath_memory-vault_rulesets" + echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ + > "$FIX/repos_hyperpolymath_memory-vault_rules_branches_main" + fi + + mkrepo hyperpolymath/plain-repo main false + echo '[]' > "$FIX/repos_hyperpolymath_plain-repo_rulesets" + echo '{"id":9001}' > "$FIX/POST_repos_hyperpolymath_plain-repo_rulesets" + echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ + > "$FIX/repos_hyperpolymath_plain-repo_rules_branches_main" + + mkrepo hyperpolymath/converged-repo main false + echo '[{"id":10,"source_type":"Repository","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_hyperpolymath_converged-repo_rulesets" + cat > "$FIX/repos_hyperpolymath_converged-repo_rulesets_10" <<'J' +{"id":10,"rules":[{"type":"deletion"},{"type":"non_fast_forward"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[]} +J + + mkrepo hyperpolymath/richer-repo main false + echo '[{"id":20,"source_type":"Repository","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_hyperpolymath_richer-repo_rulesets" + cat > "$FIX/repos_hyperpolymath_richer-repo_rulesets_20" <<'J' +{"id":20,"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"required_signatures"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[]} +J + + # same rule set and same include as the floor, but WITH a bypass actor. + mkrepo hyperpolymath/bypassed-twin main false + echo '[{"id":30,"source_type":"Repository","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_hyperpolymath_bypassed-twin_rulesets" + cat > "$FIX/repos_hyperpolymath_bypassed-twin_rulesets_30" <<'J' +{"id":30,"rules":[{"type":"deletion"},{"type":"non_fast_forward"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}, + "bypass_actors":[{"actor_id":5,"actor_type":"RepositoryRole","bypass_mode":"always"}]} +J + + mkrepo hyperpolymath/archived-repo main true + # By default NO rulesets fixture: the archived guard must return before any such read. + # In mutant mode the repo is made fully writable, so a mutant that removes the guard + # produces a REAL POST instead of dying on a missing fixture. + if [ "${1:-}" = "with-vault" ]; then + echo '[]' > "$FIX/repos_hyperpolymath_archived-repo_rulesets" + echo '{"id":9098}' > "$FIX/POST_repos_hyperpolymath_archived-repo_rulesets" + echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ + > "$FIX/repos_hyperpolymath_archived-repo_rules_branches_main" + fi + + mkrepo hyperpolymath/private-repo main false + printf 'HTTP 403: Upgrade to GitHub Pro or make this repository public\n' \ + > "$FIX/repos_hyperpolymath_private-repo_rulesets" + echo 1 > "$FIX/repos_hyperpolymath_private-repo_rulesets.rc" + + mkrepo hyperpolymath/nosourcetype-repo main false + echo '[{"id":40,"target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_hyperpolymath_nosourcetype-repo_rulesets" +} + +echo "== report mode (no --apply) ==" +build_fixtures +OUT="$(run "$SUT" --repos "$REPOS")" +check "vault is EXCLUDED-D50" "EXCLUDED-D50" "$(state "$OUT" hyperpolymath/memory-vault)" +check "bare repo is WOULD-CREATE" "WOULD-CREATE" "$(state "$OUT" hyperpolymath/plain-repo)" +check "exact floor is CONVERGED" "CONVERGED" "$(state "$OUT" hyperpolymath/converged-repo)" +check "richer cover is COVERED-BY-RICHER" "COVERED-BY-RICHER" "$(state "$OUT" hyperpolymath/richer-repo)" +check "bypassed twin is NOT converged" "COVERED-BY-RICHER" "$(state "$OUT" hyperpolymath/bypassed-twin)" +check "archived is ARCHIVED" "ARCHIVED" "$(state "$OUT" hyperpolymath/archived-repo)" +check "403 is PLAN-EXCLUDED" "PLAN-EXCLUDED" "$(state "$OUT" hyperpolymath/private-repo)" +check "no source_type is REFUSED" "REFUSED" "$(state "$OUT" hyperpolymath/nosourcetype-repo)" +check "report mode writes nothing" "0" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" + +echo "== apply mode ==" +build_fixtures +OUT="$(run "$SUT" --repos "$REPOS" --apply)" +check "bare repo is CREATED" "CREATED" "$(state "$OUT" hyperpolymath/plain-repo)" +check "exactly one write" "1" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" +check "the write is a POST" "POST" "$(cut -f1 "$FIX/PUTS.log")" +check "the write targets plain-repo" "repos/hyperpolymath/plain-repo/rulesets" "$(cut -f2 "$FIX/PUTS.log")" +POSTED="$(cut -f3 "$FIX/PUTS.log" | jq -S -c .)" +CANONJ="$(jq -S -c . "$ROOT/config/rulesets/branch-floor.json")" +check "POST body equals the canon file" "$CANONJ" "$POSTED" +check "posted bypass_actors is empty" "0" "$(printf '%s' "$POSTED" | jq '.bypass_actors | length')" +check "no write touched the vault" "0" "$(command grep -c 'memory-vault' "$FIX/PUTS.log")" +check "no write touched the archived" "0" "$(command grep -c 'archived-repo' "$FIX/PUTS.log")" + +echo "== refusals ==" +build_fixtures +OUT2="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$SUT" --repos /dev/null 2>&1)" +case "$OUT2" in *"clean sweep over nothing"*) ok "empty repo list is refused";; + *) bad "empty repo list is refused" "refusal" "$OUT2";; esac + +MISSING="$WORK/noclass"; mkdir -p "$MISSING/scripts" "$MISSING/config/rulesets" +cp "$SUT" "$MISSING/scripts/" +cp "$ROOT/config/rulesets/branch-floor.json" "$ROOT/config/rulesets/tag-floor.json" "$MISSING/config/rulesets/" +# deliberately do NOT copy gcrypt-vault-class.txt +OUT3="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$MISSING/scripts/apply-protection-floor.sh" --repos "$REPOS" 2>&1)" +case "$OUT3" in *"class file missing"*) ok "missing vault class file is a refusal";; + *) bad "missing vault class file is a refusal" "refusal" "$OUT3";; esac + +echo "== mutants (each MUST make the suite go red, for the RIGHT reason) ==" +# ๐Ÿชค A mutant written to a temp dir resolves REPO_ROOT to that dir, cannot find the canon +# file, and dies with FATAL before ANY guard runs -- so all four "reds" would measure a +# broken path rather than the defect. The mutant therefore lives in the real scripts/ dir, +# and every mutant run is asserted to have produced real output first. +MUT="$ROOT/scripts/.protection-floor-mutant.tmp.sh" +trap 'rm -rf "$WORK"; rm -f "$MUT"' EXIT + +mutant() { # name sed-expr assertion-kind arg + local name="$1" expr="$2" kind="$3" arg="$4" o got + sed "$expr" "$SUT" > "$MUT" + if ! bash -n "$MUT" 2>/dev/null; then + bad "mutant '$name'" "parses" "parse error -- red would measure the parser"; return + fi + if cmp -s "$MUT" "$SUT"; then + bad "mutant '$name'" "sed changes the script" "sed matched nothing -- the mutant is the original"; return + fi + build_fixtures with-vault + o="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$MUT" --repos "$REPOS" --apply 2>/dev/null)" + # the mutant must still RUN; a FATAL would make every check vacuous + if [ "$(printf '%s\n' "$o" | wc -l)" -lt 3 ]; then + bad "mutant '$name'" "runs and reports" "produced no report -- it died early, red is meaningless"; return + fi + case "$kind" in + wrote) got="$(command grep -c "$arg" "$FIX/PUTS.log")" + if [ "$got" -gt 0 ]; then ok "mutant '$name' dies (now POSTs to $arg)" + else bad "mutant '$name' DIES" "a POST to $arg" "no such write -- MUTANT SURVIVED"; fi ;; + state) got="$(state "$o" "${arg%%=*}")" + if [ "$got" = "${arg#*=}" ]; then ok "mutant '$name' dies (${arg%%=*} -> $got)" + else bad "mutant '$name' DIES" "${arg#*=}" "$got -- MUTANT SURVIVED"; fi ;; + esac +} + +# Baseline: with the vault fully writable, the REAL script must still write nothing to it. +build_fixtures with-vault +OUTV="$(run "$SUT" --repos "$REPOS" --apply)" +check "vault stays EXCLUDED even when writable" "EXCLUDED-D50" "$(state "$OUTV" hyperpolymath/memory-vault)" +check "vault receives no POST when writable" "0" "$(command grep -c 'memory-vault' "$FIX/PUTS.log")" +check "archived stays ARCHIVED when writable" "ARCHIVED" "$(state "$OUTV" hyperpolymath/archived-repo)" +check "archived receives no POST when writable" "0" "$(command grep -c 'archived-repo' "$FIX/PUTS.log")" + +mutant "D50 vault guard removed" \ + 's/^ if is_vault "\$repo"; then$/ if false; then/' \ + wrote "memory-vault" + +mutant "archived guard removed" \ + 's/^ if \[ "\$(printf .%s. "\$meta" | jq -r ..archived.)" = "true" \]; then$/ if false; then/' \ + wrote "archived-repo" + +mutant "bypass dropped from the shape test" \ + 's/ \&\& \[ "\$byp" = "0" \]//' \ + state "hyperpolymath/bypassed-twin=CONVERGED" + +# Removing the converged early-return does NOT reach a write: the covered-by-richer check +# catches it next. That second line of defence is the point, so this mutant is asserted on +# the STATE it corrupts, not on a POST that correctly never happens. +mutant "converged early-return removed" \ + 's/^ if \[ "\$exact_n" -eq 1 \]; then$/ if false; then/' \ + state "hyperpolymath/converged-repo=COVERED-BY-RICHER" + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] From 3d66ada567587376bccf9812a5ee6c4411eb4339 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:35:18 +0100 Subject: [PATCH 02/13] fix(rulesets): org rulesets must enter the floor cover set (#787) ORG-INHERITED was unreachable dead code. `org_n` counted active org-level rulesets of the target, but their rule types never entered `union`, which was built only from repo-level rulesets. Since the ORG-INHERITED branch required `covered == 1`, it could never be taken. Measured impact: after creating the zero-bypass org `Branch-Floor` on metadatastician (`~ALL`, verified live by an independent re-GET), a report-only run still returned 67 WOULD-CREATE. Under `--apply` that is 67 duplicate per-repo rulesets for a rule already in force org-wide. Fix: org rulesets of the target are read (cached by ruleset id, since the body is identical across every repo in the org) into their own `union_org`. Cover is then evaluated twice -- repo-level first, so a curable cover still reports COVERED-BY-RICHER, then org-level for ORG-INHERITED. The detail line now also carries the covering org rulesets' maximum `bypass_actors` count, because a cover that many actors can bypass is weaker than its rule list suggests. Tests: two new fixtures and one new mutant. - a COMPLETE org cover must report ORG-INHERITED and receive no duplicate POST - a HALF org cover (the real EstateBranching shape: `deletion`, no `non_fast_forward`) must still be WOULD-CREATE -- a half cover is not a cover - mutant `org cover dropped from the union` reproduces the shipped bug exactly and now POSTs a duplicate, so it dies The org-covered fixture is deliberately writable so the mutant produces a real duplicate POST rather than dying on a missing fixture -- the false-green trap this suite has already been bitten by twice. 34 passed, 0 failed; 5 mutants, all dead. Refs hyperpolymath/standards#787, hyperpolymath/standards#956 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-protection-floor.sh | 43 +++++++++++++++++++-- scripts/tests/protection-floor-test.sh | 53 ++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index 62f3eba88..ac3339a4c 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -78,7 +78,10 @@ REPOS_FILE="" FLOOR_EVEN_IF_COVERED=0 TMPDIR_ERR="$(mktemp -t protfloor-err.XXXXXX)" -cleanup() { rm -f "$TMPDIR_ERR"; } +# Org rulesets are IDENTICAL across every repo in the org, so their bodies are fetched +# once and cached by ruleset id rather than re-read per repo. +ORG_CACHE="$(mktemp -d -t protfloor-org.XXXXXX)" +cleanup() { rm -f "$TMPDIR_ERR"; rm -rf "$ORG_CACHE"; } trap cleanup EXIT die() { printf 'FATAL: %s\n' "$*" >&2; exit 2; } @@ -168,6 +171,7 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do org_n="$(printf '%s' "$listing" | jq "[.[]? | select(.source_type==\"Organization\" and .target==\"$TARGET\" and .enforcement==\"active\")] | length")" repo_ids="$(printf '%s' "$listing" | jq -r ".[]? | select(.source_type==\"Repository\" and .target==\"$TARGET\" and .enforcement==\"active\") | .id")" + org_ids="$(printf '%s' "$listing" | jq -r ".[]? | select(.source_type==\"Organization\" and .target==\"$TARGET\" and .enforcement==\"active\") | .id")" # 5. Walk the active repo-level rulesets of this target and classify. exact_n=0; exact_ids=""; union="" @@ -204,6 +208,31 @@ EOF continue fi + # 5b. Org-inherited rulesets of this target also put rules IN FORCE. They are never + # writable per repo, so they are kept in their OWN union: a repo-level cover can be + # cured here, an org-level cover must be cured once at the org. Omitting this union + # is what made ORG-INHERITED unreachable and reported 67 covered repos as WOULD-CREATE. + union_org=""; org_byp_max=0; org_read_ok=1 + if [ -n "$org_ids" ]; then + while IFS= read -r rid; do + [ -n "$rid" ] || continue + cache="$ORG_CACHE/$rid" + if [ ! -s "$cache" ]; then + gh api "repos/$repo/rulesets/$rid" > "$cache" 2>/dev/null || : + fi + if [ ! -s "$cache" ]; then org_read_ok=0; break; fi + union_org="$union_org,$(jq -r '[.rules[].type] | sort | join(",")' "$cache")" + b="$(jq -r '[.bypass_actors[]?] | length' "$cache")" + [ "$b" -gt "$org_byp_max" ] && org_byp_max="$b" + done < "$FIX/repos_hyperpolymath_nosourcetype-repo_rulesets" + + # An ORG-inherited ruleset that CARRIES the whole floor. It is not writable per repo, + # so the only correct answer is ORG-INHERITED -- never a per-repo duplicate. + mkrepo metadatastician/org-covered-repo main false + echo '[{"id":60,"source_type":"Organization","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_metadatastician_org-covered-repo_rulesets" + cat > "$FIX/repos_metadatastician_org-covered-repo_rulesets_60" <<'J' +{"id":60,"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"required_signatures"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[]} +J + # Writable on purpose: a mutant that drops the org union must produce a REAL duplicate + # POST here, not die on a missing fixture (the false-green trap this suite already hit). + echo '{"id":9060}' > "$FIX/POST_repos_metadatastician_org-covered-repo_rulesets" + echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ + > "$FIX/repos_metadatastician_org-covered-repo_rules_branches_main" + + # The real EstateBranching shape: an org ruleset carrying HALF the floor (deletion, no + # non_fast_forward). A half cover is NOT a cover; this repo must still be WOULD-CREATE. + mkrepo metadatastician/org-halffloor-repo main false + echo '[{"id":61,"source_type":"Organization","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_metadatastician_org-halffloor-repo_rulesets" + cat > "$FIX/repos_metadatastician_org-halffloor-repo_rulesets_61" <<'J' +{"id":61,"rules":[{"type":"deletion"},{"type":"pull_request"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[{"actor_id":1}]} +J + echo '{"id":9061}' > "$FIX/POST_repos_metadatastician_org-halffloor-repo_rulesets" + echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ + > "$FIX/repos_metadatastician_org-halffloor-repo_rules_branches_main" } echo "== report mode (no --apply) ==" @@ -154,16 +184,22 @@ check "bypassed twin is NOT converged" "COVERED-BY-RICHER" "$(state "$OUT" hyp check "archived is ARCHIVED" "ARCHIVED" "$(state "$OUT" hyperpolymath/archived-repo)" check "403 is PLAN-EXCLUDED" "PLAN-EXCLUDED" "$(state "$OUT" hyperpolymath/private-repo)" check "no source_type is REFUSED" "REFUSED" "$(state "$OUT" hyperpolymath/nosourcetype-repo)" +check "complete org cover is ORG-INHERITED" "ORG-INHERITED" "$(state "$OUT" metadatastician/org-covered-repo)" +check "HALF org cover is not a cover" "WOULD-CREATE" "$(state "$OUT" metadatastician/org-halffloor-repo)" check "report mode writes nothing" "0" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" echo "== apply mode ==" build_fixtures OUT="$(run "$SUT" --repos "$REPOS" --apply)" check "bare repo is CREATED" "CREATED" "$(state "$OUT" hyperpolymath/plain-repo)" -check "exactly one write" "1" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" -check "the write is a POST" "POST" "$(cut -f1 "$FIX/PUTS.log")" -check "the write targets plain-repo" "repos/hyperpolymath/plain-repo/rulesets" "$(cut -f2 "$FIX/PUTS.log")" -POSTED="$(cut -f3 "$FIX/PUTS.log" | jq -S -c .)" +check "half org cover still CREATED" "CREATED" "$(state "$OUT" metadatastician/org-halffloor-repo)" +# Exactly two repos lack a floor in force: the bare one and the HALF-org-covered one. +# Every other fixture must be left alone, so the count is an assertion in both directions. +check "exactly two writes" "2" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" +check "every write is a POST" "POST" "$(cut -f1 "$FIX/PUTS.log" | sort -u)" +check "one write targets plain-repo" "1" "$(command grep -c 'repos/hyperpolymath/plain-repo/rulesets' "$FIX/PUTS.log")" +check "one write targets half-org repo" "1" "$(command grep -c 'repos/metadatastician/org-halffloor-repo/rulesets' "$FIX/PUTS.log")" +POSTED="$(command grep 'plain-repo' "$FIX/PUTS.log" | cut -f3 | jq -S -c .)" CANONJ="$(jq -S -c . "$ROOT/config/rulesets/branch-floor.json")" check "POST body equals the canon file" "$CANONJ" "$POSTED" check "posted bypass_actors is empty" "0" "$(printf '%s' "$POSTED" | jq '.bypass_actors | length')" @@ -224,6 +260,8 @@ check "vault stays EXCLUDED even when writable" "EXCLUDED-D50" "$(state "$OUTV" check "vault receives no POST when writable" "0" "$(command grep -c 'memory-vault' "$FIX/PUTS.log")" check "archived stays ARCHIVED when writable" "ARCHIVED" "$(state "$OUTV" hyperpolymath/archived-repo)" check "archived receives no POST when writable" "0" "$(command grep -c 'archived-repo' "$FIX/PUTS.log")" +check "org-covered stays ORG-INHERITED" "ORG-INHERITED" "$(state "$OUTV" metadatastician/org-covered-repo)" +check "org-covered receives no duplicate POST" "0" "$(command grep -c 'org-covered-repo' "$FIX/PUTS.log")" mutant "D50 vault guard removed" \ 's/^ if is_vault "\$repo"; then$/ if false; then/' \ @@ -240,6 +278,13 @@ mutant "bypass dropped from the shape test" \ # Removing the converged early-return does NOT reach a write: the covered-by-richer check # catches it next. That second line of defence is the point, so this mutant is asserted on # the STATE it corrupts, not on a POST that correctly never happens. +# The bug this suite was extended for: org rulesets were COUNTED (org_n) but their rule +# types never entered the cover set, so ORG-INHERITED was unreachable and 67 org-covered +# repos reported WOULD-CREATE. Under --apply that is 67 duplicate rulesets. +mutant "org cover dropped from the union" \ + 's/",\$union,\$union_org,"/",$union,"/' \ + wrote "org-covered-repo" + mutant "converged early-return removed" \ 's/^ if \[ "\$exact_n" -eq 1 \]; then$/ if false; then/' \ state "hyperpolymath/converged-repo=COVERED-BY-RICHER" From 4c8b2f8328b60cd0a5fa7bf8d10a4fd50bbc640f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:45:59 +0100 Subject: [PATCH 03/13] Update scripts/apply-protection-floor.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/apply-protection-floor.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index ac3339a4c..a6582dc62 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -156,6 +156,7 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do if ! listing="$(gh api "repos/$repo/rulesets" 2>"$TMPDIR_ERR")"; then err="$(cat "$TMPDIR_ERR" 2>/dev/null)" case "$err" in + *"rate limit"*|*"abuse"*) report "$repo" "UNKNOWN" "rulesets list throttled: ${err%%$'\n'*}" ;; *403*|*422*|*"upgrade"*) report "$repo" "PLAN-EXCLUDED" "rulesets endpoint refused: ${err%%$'\n'*}" ;; *) report "$repo" "UNKNOWN" "rulesets list failed: ${err%%$'\n'*}" ;; esac From dec21a32fe67a8ef5ff1cdd9396ef25a39b73c6f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:52:19 +0000 Subject: [PATCH 04/13] docs(scripts): document protection floor and test helper functions --- scripts/apply-protection-floor.sh | 4 ++++ scripts/tests/protection-floor-test.sh | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index a6582dc62..b4ced99c5 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -81,10 +81,13 @@ TMPDIR_ERR="$(mktemp -t protfloor-err.XXXXXX)" # Org rulesets are IDENTICAL across every repo in the org, so their bodies are fetched # once and cached by ruleset id rather than re-read per repo. ORG_CACHE="$(mktemp -d -t protfloor-org.XXXXXX)" +# Remove the temporary error file and organization-ruleset cache. cleanup() { rm -f "$TMPDIR_ERR"; rm -rf "$ORG_CACHE"; } trap cleanup EXIT +# Print a fatal error and exit with the script's refusal status. die() { printf 'FATAL: %s\n' "$*" >&2; exit 2; } +# Emit one tab-separated repository status row. report() { printf '%s\t%s\t%s\n' "$1" "$2" "${3:-}"; } while [ $# -gt 0 ]; do @@ -127,6 +130,7 @@ VAULTS="$(command grep -vE '^[[:space:]]*(#|$)' "$VAULT_CLASS" | tr -d ' \t')" TARGETS="$(command grep -vE '^[[:space:]]*(#|$)' "$REPOS_FILE" | tr -d ' \t' | sort -u)" [ -n "$TARGETS" ] || die "refusing to report a clean sweep over nothing: $REPOS_FILE yielded no repos" +# Return success when the repository is an explicitly listed gcrypt vault. is_vault() { printf '%s\n' "$VAULTS" | command grep -qxF "$1" } diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh index 9c9719ead..89d1d4602 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/protection-floor-test.sh @@ -19,8 +19,11 @@ SUT="$ROOT/scripts/apply-protection-floor.sh" [ -r "$SUT" ] || { echo "FATAL: script under test missing: $SUT"; exit 2; } PASS=0; FAIL=0 +# Record a passing assertion and print its description. ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +# Record a failing assertion and print its expected and actual values. bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"; } +# Compare expected and actual values, then record the assertion result. check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "$2" "$3"; fi; } WORK="$(mktemp -d -t protfloor-test.XXXXXX)" @@ -55,11 +58,15 @@ cat "$F" SHIM chmod 755 "$BIN/gh" +# Run a script with the fixture-backed GitHub CLI shim. run() { GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$1" "${@:2}" 2>/dev/null; } +# Extract one repository's state from tab-separated report output. state() { printf '%s\n' "$1" | awk -F'\t' -v r="$2" '$1==r{print $2}'; } +# Recreate the fixture directory and initialize an empty write log. reset_fix() { rm -rf "$FIX"; mkdir -p "$FIX"; : > "$FIX/PUTS.log"; } -mkrepo() { # name default archived +# Write repository metadata from a name, default branch, and archived flag. +mkrepo() { printf '{"archived":%s,"default_branch":"%s"}\n' "$3" "$2" > "$FIX/repos_$(printf '%s' "$1" | tr '/' '_')" } @@ -77,7 +84,8 @@ metadatastician/org-covered-repo metadatastician/org-halffloor-repo EOF -build_fixtures() { # $1 = "with-vault" to give the vault a complete, writable fixture set +# Rebuild fixtures, optionally making excluded repositories fully writable. +build_fixtures() { reset_fix # By default memory-vault gets NO fixtures at all. The D50 guard must return before any # read, so every missing fixture here asserts that nothing was queried. @@ -228,7 +236,8 @@ echo "== mutants (each MUST make the suite go red, for the RIGHT reason) ==" MUT="$ROOT/scripts/.protection-floor-mutant.tmp.sh" trap 'rm -rf "$WORK"; rm -f "$MUT"' EXIT -mutant() { # name sed-expr assertion-kind arg +# Run a named mutation and assert the expected state change or write. +mutant() { local name="$1" expr="$2" kind="$3" arg="$4" o got sed "$expr" "$SUT" > "$MUT" if ! bash -n "$MUT" 2>/dev/null; then From 8de0ea04128aace1a359771f25644f09a683e3b6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:36:19 +0100 Subject: [PATCH 05/13] docs(rulesets): README said the applier was missing (#787) The "Nothing here is applied automatically" section predated both appliers and told readers the propagation mechanism did not exist. It now names apply-branch-gates.sh and apply-protection-floor.sh, and records the four findings a future reader needs before touching a floor: - a floor read without bypass_actors is not a floor (EstateTagging carries every floor rule and three actors that bypass it always) - org cover is a distinct state from repo cover, discriminated by .source_type; an absent .source_type is a refusal, not a default to the writable arm - a half cover is not a cover (EstateBranching: deletion, no non_fast_forward) - the gcrypt vault class must be excluded by explicit list, never a name regex Also documents why the floor canon is branch-floor.json / tag-floor.json rather than base*.json: base.json is the richer tier and the collision would mislead. Refs hyperpolymath/standards#787 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- config/rulesets/README.adoc | 52 +++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/config/rulesets/README.adoc b/config/rulesets/README.adoc index 3d31b8791..d8e3e296a 100644 --- a/config/rulesets/README.adoc +++ b/config/rulesets/README.adoc @@ -6,7 +6,13 @@ baseline; `Optimus-Extras.json` carries the extras for repos on the Optimus prof `gates-only.json` / `gates.json` describe which workflow files become required contexts; `immutable-tags.json` is the tag canon. -== Nothing here is applied automatically +`branch-floor.json` and `tag-floor.json` are the *base protection floor* โ€” deliberately +**not** named `base*.json`, because `base.json` is the richer tier and the collision would +mislead every future reader. The floor is `deletion` + `non_fast_forward` with +`bypass_actors: []`; branch target scopes to `~DEFAULT_BRANCH`, tag target to `~ALL`. +`gcrypt-vault-class.txt` is the D50 exclusion class for that floor (see below). + +== The floor IS applied by a committed script; the richer tiers still are not `scripts/apply-tag-ruleset-canon.sh` converges *only* the tag ruleset, and its own header records the consequence: after the 2026-09-11 deployment wave the branch-side remediation was @@ -14,9 +20,51 @@ records the consequence: after the 2026-09-11 deployment wave the branch-side re `config/rulesets/`". That gap is why `Optimus-Extras.json` could keep four constraints ยง7.3 retired (`code_coverage`, `code_quality`, `required_deployments`, `copilot_code_review`) and re-add them on the next manual convergence, on 120-162 active rulesets estate-wide. The file is -now trimmed; the propagation mechanism is still missing by design, and this note is the +now trimmed; that propagation gap is still open for the richer tiers, and this note is the reminder that a template fix without an applier is a half fix. +`scripts/apply-branch-gates.sh` (required-status-check gates) and +`scripts/apply-protection-floor.sh` (the base floor) close the gap for their own scopes. +Both default to **report-only**; writes need `--apply`, never the inverse. + +=== Reading a floor requires `bypass_actors`, not just the rule list + +A ruleset's rules say what it forbids; `bypass_actors` says whom it forbids it to. A floor +read without the second column is not a floor. Measured 2026-09-23: `metadatastician`'s +`EstateTagging` carries every floor rule and **three actors that bypass it `always`**, and +`EstateBranching` carries **nine** bypass actors โ€” so "add the missing field to +`EstateBranching`" would have shipped a floor nine actors could walk through. The cure is a +**standalone** zero-bypass ruleset alongside (`Branch-Floor` 23868655, `Tag-Floor` 23868851), +never an edit to a richer ruleset: a floor that lives inside a richer ruleset dies the day +that ruleset is disabled, as 375 rulesets were on 2026-09-22. + +=== Org cover is a distinct state from repo cover + +`GET /repos/{o}/{r}/rulesets` returns org-inherited rulesets too, discriminated by +`.source_type`. They put rules **in force** but cannot be written per repo, so the applier +keeps them in a separate cover set and reports `ORG-INHERITED` rather than +`COVERED-BY-RICHER`. Conflating the two in either direction is a real defect: counting org +rulesets without unioning their rule types made `ORG-INHERITED` unreachable and reported 67 +already-covered repos as needing a write. An **absent** `.source_type` is a REFUSAL, never a +default to the writable arm. + +=== A half cover is not a cover + +`EstateBranching` carries `deletion` and not `non_fast_forward`. A repo covered only by it is +`WOULD-CREATE`, not covered. The regression suite pins this with a fixture of exactly that +shape. + +=== ๐Ÿšจ The gcrypt vault class must be excluded, by explicit list + +`hyperpolymath/dev-notes-vault` and `hyperpolymath/memory-vault` carry a deliberately +`deletion`-only `Gcrypt-Vault-Guard` (**D50**): git-remote-gcrypt **force-pushes on every +sync**, so `non_fast_forward` would silently stop the hourly backup at the next timer fire. +Any logic reading *"has `deletion`, lacks `non_fast_forward` โ‡’ complete the floor"* writes +exactly that rule. Membership is the explicit list in `gcrypt-vault-class.txt` and **never a +name regex** โ€” `reasonably-good-token-vault` and `befunge93-vault-cracker` match `/vault/` +and genuinely need the floor. A missing class file is a hard refusal, because an absent +exclusion list is indistinguishable from an empty one. + `scripts/plan-ruleset-constraint-repair.rb` is the sanctioned planner: it emits a reviewable PUT body for exactly those retired constraints, optionally dropping one integration's bypass once GitHub has rejected that app as no longer installed. It never calls GitHub. From c815fb3104ec2d9789ff204563afa569f21adc47 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:52:09 +0100 Subject: [PATCH 06/13] fix(rulesets): a throttled 403 is UNKNOWN, not PLAN-EXCLUDED (#787) GitHub answers a primary rate limit, a secondary limit and an abuse trip with 403 -- the same status a private repo on a plan without rulesets returns. The discriminator matched `*403*` first, so a throttled read was recorded as "private repo / plan limit" and the protection gap was silently under-reported. Caught live: the estate sweep was stopped with quota at 598 and 163 repos left, before a single row could be misfiled. is_throttled() now classifies on the lowercased body (rate limit, rate-limit, abuse detection, retry-after, http 429) ahead of the plan arm, and reports UNKNOWN -- a throttled read is skipped, never recorded. Two fixtures pin it (primary and secondary limit), the existing private-repo 403 stays as the negative control proving the throttle arm did not swallow the plan arm, and a mutant silencing the guard flips throttled-repo back to PLAN-EXCLUDED. 37 passed, 0 failed, 6 mutants dead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-protection-floor.sh | 19 +++++++++++++-- scripts/tests/protection-floor-test.sh | 32 +++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index b4ced99c5..a3b09a8d8 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -130,6 +130,18 @@ VAULTS="$(command grep -vE '^[[:space:]]*(#|$)' "$VAULT_CLASS" | tr -d ' \t')" TARGETS="$(command grep -vE '^[[:space:]]*(#|$)' "$REPOS_FILE" | tr -d ' \t' | sort -u)" [ -n "$TARGETS" ] || die "refusing to report a clean sweep over nothing: $REPOS_FILE yielded no repos" +# A THROTTLED READ IS NOT A PLAN EXCLUSION. GitHub answers a primary rate limit, a +# secondary limit and an abuse trip all with 403 -- the same status a private repo on a +# plan without rulesets returns. Only the body text separates them, so throttling must +# be classified FIRST: matching *403* alone records a throttled repo as PLAN-EXCLUDED +# ("private repo / plan limit"), which silently UNDER-REPORTS the protection gap. +is_throttled() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + *"rate limit"*|*"rate-limit"*|*"abuse"*|*"retry-after"*|*"http 429"*) return 0 ;; + *) return 1 ;; + esac +} + # Return success when the repository is an explicitly listed gcrypt vault. is_vault() { printf '%s\n' "$VAULTS" | command grep -qxF "$1" @@ -156,11 +168,14 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do fi default_branch="$(printf '%s' "$meta" | jq -r '.default_branch // empty')" - # 3. List rulesets. 403/422 here is the private-repo / plan-limit arm. + # 3. List rulesets. A 403 is ambiguous: throttle first, THEN the private-repo / plan arm. if ! listing="$(gh api "repos/$repo/rulesets" 2>"$TMPDIR_ERR")"; then err="$(cat "$TMPDIR_ERR" 2>/dev/null)" + if is_throttled "$err"; then + report "$repo" "UNKNOWN" "rulesets list throttled; skipped rather than recorded: ${err%%$'\n'*}" + continue + fi case "$err" in - *"rate limit"*|*"abuse"*) report "$repo" "UNKNOWN" "rulesets list throttled: ${err%%$'\n'*}" ;; *403*|*422*|*"upgrade"*) report "$repo" "PLAN-EXCLUDED" "rulesets endpoint refused: ${err%%$'\n'*}" ;; *) report "$repo" "UNKNOWN" "rulesets list failed: ${err%%$'\n'*}" ;; esac diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh index 89d1d4602..65c244f1c 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/protection-floor-test.sh @@ -66,7 +66,7 @@ state() { printf '%s\n' "$1" | awk -F'\t' -v r="$2" '$1==r{print $2}'; } reset_fix() { rm -rf "$FIX"; mkdir -p "$FIX"; : > "$FIX/PUTS.log"; } # Write repository metadata from a name, default branch, and archived flag. -mkrepo() { +mkrepo() { # name default-branch archived printf '{"archived":%s,"default_branch":"%s"}\n' "$3" "$2" > "$FIX/repos_$(printf '%s' "$1" | tr '/' '_')" } @@ -79,13 +79,15 @@ hyperpolymath/richer-repo hyperpolymath/bypassed-twin hyperpolymath/archived-repo hyperpolymath/private-repo +hyperpolymath/throttled-repo +hyperpolymath/secondary-throttled-repo hyperpolymath/nosourcetype-repo metadatastician/org-covered-repo metadatastician/org-halffloor-repo EOF # Rebuild fixtures, optionally making excluded repositories fully writable. -build_fixtures() { +build_fixtures() { # $1 = "with-vault" to give the vault a complete, writable fixture set reset_fix # By default memory-vault gets NO fixtures at all. The D50 guard must return before any # read, so every missing fixture here asserts that nothing was queried. @@ -148,6 +150,19 @@ J > "$FIX/repos_hyperpolymath_private-repo_rulesets" echo 1 > "$FIX/repos_hyperpolymath_private-repo_rulesets.rc" + # A THROTTLE ALSO ANSWERS 403, with the SAME status as the plan refusal above. + # These two exist so the discriminator cannot go back to matching *403* alone: + # that recorded a throttled repo as PLAN-EXCLUDED and under-reported the gap. + mkrepo hyperpolymath/throttled-repo main false + printf 'HTTP 403: API rate limit exceeded for user ID 12345. (https://api.github.com/repos/hyperpolymath/throttled-repo/rulesets)\n' \ + > "$FIX/repos_hyperpolymath_throttled-repo_rulesets" + echo 1 > "$FIX/repos_hyperpolymath_throttled-repo_rulesets.rc" + + mkrepo hyperpolymath/secondary-throttled-repo main false + printf 'HTTP 403: You have exceeded a secondary rate limit. Please wait a few minutes before you try again.\n' \ + > "$FIX/repos_hyperpolymath_secondary-throttled-repo_rulesets" + echo 1 > "$FIX/repos_hyperpolymath_secondary-throttled-repo_rulesets.rc" + mkrepo hyperpolymath/nosourcetype-repo main false echo '[{"id":40,"target":"branch","enforcement":"active"}]' \ > "$FIX/repos_hyperpolymath_nosourcetype-repo_rulesets" @@ -191,6 +206,10 @@ check "richer cover is COVERED-BY-RICHER" "COVERED-BY-RICHER" "$(state "$OUT" hy check "bypassed twin is NOT converged" "COVERED-BY-RICHER" "$(state "$OUT" hyperpolymath/bypassed-twin)" check "archived is ARCHIVED" "ARCHIVED" "$(state "$OUT" hyperpolymath/archived-repo)" check "403 is PLAN-EXCLUDED" "PLAN-EXCLUDED" "$(state "$OUT" hyperpolymath/private-repo)" +# The plan arm above is the NEGATIVE CONTROL: it proves the throttle arm below did not +# simply swallow every 403. A throttled read is UNKNOWN -- skipped, never recorded. +check "rate-limit 403 is UNKNOWN" "UNKNOWN" "$(state "$OUT" hyperpolymath/throttled-repo)" +check "secondary-limit 403 is UNKNOWN" "UNKNOWN" "$(state "$OUT" hyperpolymath/secondary-throttled-repo)" check "no source_type is REFUSED" "REFUSED" "$(state "$OUT" hyperpolymath/nosourcetype-repo)" check "complete org cover is ORG-INHERITED" "ORG-INHERITED" "$(state "$OUT" metadatastician/org-covered-repo)" check "HALF org cover is not a cover" "WOULD-CREATE" "$(state "$OUT" metadatastician/org-halffloor-repo)" @@ -237,7 +256,7 @@ MUT="$ROOT/scripts/.protection-floor-mutant.tmp.sh" trap 'rm -rf "$WORK"; rm -f "$MUT"' EXIT # Run a named mutation and assert the expected state change or write. -mutant() { +mutant() { # name sed-expr assertion-kind(wrote|state) arg local name="$1" expr="$2" kind="$3" arg="$4" o got sed "$expr" "$SUT" > "$MUT" if ! bash -n "$MUT" 2>/dev/null; then @@ -276,6 +295,13 @@ mutant "D50 vault guard removed" \ 's/^ if is_vault "\$repo"; then$/ if false; then/' \ wrote "memory-vault" +# The pending-fix defect: any 403 mapped to PLAN-EXCLUDED, so a rate-limit refusal was +# filed as "private repo / plan limit". It writes nothing either way, so the tell is the +# STATE, not a POST -- a mutant that silences the throttle arm must flip it back. +mutant "throttle arm removed from the 403 split" \ + 's/^ if is_throttled "\$err"; then$/ if false; then/' \ + state "hyperpolymath/throttled-repo=PLAN-EXCLUDED" + mutant "archived guard removed" \ 's/^ if \[ "\$(printf .%s. "\$meta" | jq -r ..archived.)" = "true" \]; then$/ if false; then/' \ wrote "archived-repo" From 34a5404e54333397b4aebf64973be822f53dd244 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:58:49 +0100 Subject: [PATCH 07/13] fix(rulesets): stop the sweep at the rate wall, don't grind (#787) A throttle is a property of the CREDENTIAL, not of the repo: once the hourly window is spent, every remaining repo fails identically. Measured twice today -- a 267-repo apply ran straight through the wall and filed 111 repos as UNKNOWN, and a second run filed 93 more before it was stopped. UNKNOWN is the honest state for one throttled read, but 111 of them read as "measured and unknowable" when they mean "never looked". note_throttled() counts CONSECUTIVE throttled reads and aborts the sweep at three with an ABORTED row naming the cure. A single secondary-limit blip is tolerated: any read that gets through clears the count. The counter is wired into all three places the wall actually shows -- the first repo read, the rulesets list, and (on --apply) the POST. A dedicated target list drives the new section: three repos named to sort FIRST, then plain-repo, which must never be reached. A mutant that neuters the abort reaches it and writes to it. 41 passed, 0 failed, 7 mutants dead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-protection-floor.sh | 30 ++++++++++++++++-- scripts/tests/protection-floor-test.sh | 43 ++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index a3b09a8d8..c10dec9d3 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -142,6 +142,22 @@ is_throttled() { esac } +# A throttle is a property of the CREDENTIAL, not of the repo: once the hourly window is +# spent every remaining repo fails identically, so grinding on turns a 267-repo sweep into +# 267 UNKNOWN rows -- measured twice on 2026-09-23. Stop once the wall is CONFIRMED, but +# tolerate a single secondary-limit blip: the count is CONSECUTIVE and any read that +# succeeds clears it. +THROTTLE_LIMIT=3 +throttled=0 +note_throttled() { # repo what-was-read + throttled=$((throttled + 1)) + report "$1" "UNKNOWN" "$2 throttled; skipped rather than recorded" + if [ "$throttled" -ge "$THROTTLE_LIMIT" ]; then + report "-" "ABORTED" "$THROTTLE_LIMIT consecutive throttled reads: the quota window is spent, so every remaining repo would report UNKNOWN. Re-run after the reset -- trust the X-RateLimit-Reset header, not gh api rate_limit, which has reported 5000 remaining against a header saying 0." + exit 3 + fi +} + # Return success when the repository is an explicitly listed gcrypt vault. is_vault() { printf '%s\n' "$VAULTS" | command grep -qxF "$1" @@ -160,7 +176,13 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do fi # 2. Archived repos 403 on a ruleset write while every GET succeeds. - meta="$(gh api "repos/$repo" 2>/dev/null)" || { report "$repo" "UNKNOWN" "repos/$repo read failed"; continue; } + if ! meta="$(gh api "repos/$repo" 2>"$TMPDIR_ERR")"; then + err="$(cat "$TMPDIR_ERR" 2>/dev/null)" + if is_throttled "$err"; then note_throttled "$repo" "repos/$repo"; continue; fi + report "$repo" "UNKNOWN" "repos/$repo read failed: ${err%%$'\n'*}" + continue + fi + throttled=0 # a read got through; the window is not the problem [ -n "$meta" ] || { report "$repo" "UNKNOWN" "repos/$repo returned empty"; continue; } if [ "$(printf '%s' "$meta" | jq -r '.archived')" = "true" ]; then report "$repo" "ARCHIVED" "ruleset POST 403s on an archived repo; unarchive/write/re-archive is a separate, explicit act" @@ -172,7 +194,7 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do if ! listing="$(gh api "repos/$repo/rulesets" 2>"$TMPDIR_ERR")"; then err="$(cat "$TMPDIR_ERR" 2>/dev/null)" if is_throttled "$err"; then - report "$repo" "UNKNOWN" "rulesets list throttled; skipped rather than recorded: ${err%%$'\n'*}" + note_throttled "$repo" "rulesets list" continue fi case "$err" in @@ -285,7 +307,9 @@ EOF fi if ! created="$(printf '%s' "$CANON_BODY" | gh api --method POST "repos/$repo/rulesets" --input - 2>"$TMPDIR_ERR")"; then - report "$repo" "UNKNOWN" "POST failed: $(head -1 "$TMPDIR_ERR" 2>/dev/null)" + err="$(cat "$TMPDIR_ERR" 2>/dev/null)" + if is_throttled "$err"; then note_throttled "$repo" "ruleset POST"; continue; fi + report "$repo" "UNKNOWN" "POST failed: ${err%%$'\n'*}" continue fi new_id="$(printf '%s' "$created" | jq -r '.id // empty')" diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh index 65c244f1c..b787d1995 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/protection-floor-test.sh @@ -71,6 +71,16 @@ mkrepo() { # name default-branch archived } REPOS="$WORK/repos.txt" +# A SECOND target list for the backoff section. The applier sorts its targets, so the +# three throttled repos are named to sort FIRST and plain-repo to sort after them: +# the assertion is that the sweep never reaches it. +THR="$WORK/throttle-repos.txt" +cat > "$THR" <<'EOF' +hyperpolymath/aaa-throttle-1 +hyperpolymath/aaa-throttle-2 +hyperpolymath/aaa-throttle-3 +hyperpolymath/plain-repo +EOF cat > "$REPOS" <<'EOF' hyperpolymath/memory-vault hyperpolymath/plain-repo @@ -163,6 +173,17 @@ J > "$FIX/repos_hyperpolymath_secondary-throttled-repo_rulesets" echo 1 > "$FIX/repos_hyperpolymath_secondary-throttled-repo_rulesets.rc" + # The wall shows on the FIRST read of a repo, before any ruleset endpoint is touched. + mkrepo hyperpolymath/aaa-throttle-1 main false + mkrepo hyperpolymath/aaa-throttle-2 main false + mkrepo hyperpolymath/aaa-throttle-3 main false + printf 'HTTP 403: API rate limit exceeded for user ID 12345.\n' > "$FIX/repos_hyperpolymath_aaa-throttle-1" + printf 'HTTP 403: API rate limit exceeded for user ID 12345.\n' > "$FIX/repos_hyperpolymath_aaa-throttle-2" + printf 'HTTP 403: API rate limit exceeded for user ID 12345.\n' > "$FIX/repos_hyperpolymath_aaa-throttle-3" + echo 1 > "$FIX/repos_hyperpolymath_aaa-throttle-1.rc" + echo 1 > "$FIX/repos_hyperpolymath_aaa-throttle-2.rc" + echo 1 > "$FIX/repos_hyperpolymath_aaa-throttle-3.rc" + mkrepo hyperpolymath/nosourcetype-repo main false echo '[{"id":40,"target":"branch","enforcement":"active"}]' \ > "$FIX/repos_hyperpolymath_nosourcetype-repo_rulesets" @@ -233,6 +254,16 @@ check "posted bypass_actors is empty" "0" "$(printf '%s' "$PO check "no write touched the vault" "0" "$(command grep -c 'memory-vault' "$FIX/PUTS.log")" check "no write touched the archived" "0" "$(command grep -c 'archived-repo' "$FIX/PUTS.log")" +echo "== throttle backoff ==" +# A throttle is not a per-repo property. Three consecutive throttled reads mean the +# window is spent, so the sweep must ABORT: grinding on filed 267 repos as UNKNOWN twice +# on 2026-09-23, which READS as \"measured and unknowable\" when it means \"never looked\". +build_fixtures +OUTT="$(run "$SUT" --repos "$THR" --apply)" +check "third consecutive throttle aborts" "ABORTED" "$(state "$OUTT" -)" +check "the repo beyond the wall is not reported" "" "$(state "$OUTT" hyperpolymath/plain-repo)" +check "an aborted sweep writes nothing" "0" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" + echo "== refusals ==" build_fixtures OUT2="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$SUT" --repos /dev/null 2>&1)" @@ -256,8 +287,8 @@ MUT="$ROOT/scripts/.protection-floor-mutant.tmp.sh" trap 'rm -rf "$WORK"; rm -f "$MUT"' EXIT # Run a named mutation and assert the expected state change or write. -mutant() { # name sed-expr assertion-kind(wrote|state) arg - local name="$1" expr="$2" kind="$3" arg="$4" o got +mutant() { # name sed-expr assertion-kind(wrote|state) arg [repos-file] + local name="$1" expr="$2" kind="$3" arg="$4" repos="${5:-$REPOS}" o got sed "$expr" "$SUT" > "$MUT" if ! bash -n "$MUT" 2>/dev/null; then bad "mutant '$name'" "parses" "parse error -- red would measure the parser"; return @@ -266,7 +297,7 @@ mutant() { # name sed-expr assertion-kind(wrote|state) arg bad "mutant '$name'" "sed changes the script" "sed matched nothing -- the mutant is the original"; return fi build_fixtures with-vault - o="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$MUT" --repos "$REPOS" --apply 2>/dev/null)" + o="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$MUT" --repos "$repos" --apply 2>/dev/null)" # the mutant must still RUN; a FATAL would make every check vacuous if [ "$(printf '%s\n' "$o" | wc -l)" -lt 3 ]; then bad "mutant '$name'" "runs and reports" "produced no report -- it died early, red is meaningless"; return @@ -324,5 +355,11 @@ mutant "converged early-return removed" \ 's/^ if \[ "\$exact_n" -eq 1 \]; then$/ if false; then/' \ state "hyperpolymath/converged-repo=COVERED-BY-RICHER" +# Without the abort the sweep grinds through the whole list against a spent window, so the +# repo beyond the wall is REACHED and written -- the 267-UNKNOWN failure, in miniature. +mutant "throttle backoff removed" \ + 's/^ exit 3$/ throttled=0/' \ + wrote "plain-repo" "$THR" + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] From 209987e1a1851de58e217b5626b4b9892555a491 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:02:06 +0100 Subject: [PATCH 08/13] Update scripts/apply-protection-floor.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/apply-protection-floor.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index c10dec9d3..f3de1ab5e 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -227,9 +227,13 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do fi types="$(printf '%s' "$body" | jq -r '[.rules[].type] | sort | join(",")')" inc="$(printf '%s' "$body" | jq -c '.conditions.ref_name.include')" + exc="$(printf '%s' "$body" | jq -c '[.conditions.ref_name.exclude[]?] | length')" byp="$(printf '%s' "$body" | jq -c '[.bypass_actors[]?] | length')" - union="$union,$types" - if [ "$types" = "$FLOOR_TYPES" ] && [ "$inc" = "$WANT_INCLUDE" ] && [ "$byp" = "0" ]; then + if [ "$exc" = "0" ] && printf '%s' "$body" | jq -e --argjson w "$WANT_INCLUDE" \ + 'any(.conditions.ref_name.include[]?; . == $w[0])' >/dev/null 2>&1; then + union="$union,$types" + fi + if [ "$types" = "$FLOOR_TYPES" ] && [ "$inc" = "$WANT_INCLUDE" ] && [ "$exc" = "0" ] && [ "$byp" = "0" ]; then exact_n=$((exact_n + 1)); exact_ids="$exact_ids $rid" fi done < Date: Wed, 23 Sep 2026 10:02:20 +0100 Subject: [PATCH 09/13] Update scripts/tests/protection-floor-test.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/tests/protection-floor-test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh index b787d1995..24d0b269b 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/protection-floor-test.sh @@ -283,8 +283,9 @@ echo "== mutants (each MUST make the suite go red, for the RIGHT reason) ==" # file, and dies with FATAL before ANY guard runs -- so all four "reds" would measure a # broken path rather than the defect. The mutant therefore lives in the real scripts/ dir, # and every mutant run is asserted to have produced real output first. -MUT="$ROOT/scripts/.protection-floor-mutant.tmp.sh" -trap 'rm -rf "$WORK"; rm -f "$MUT"' EXIT +MUTROOT="$WORK/mutroot"; mkdir -p "$MUTROOT/scripts" "$MUTROOT/config/rulesets" +cp "$ROOT"/config/rulesets/*.json "$ROOT/config/rulesets/gcrypt-vault-class.txt" "$MUTROOT/config/rulesets/" +MUT="$MUTROOT/scripts/apply-protection-floor.sh" # Run a named mutation and assert the expected state change or write. mutant() { # name sed-expr assertion-kind(wrote|state) arg [repos-file] From 640044647b65b311a0086d402f24fc6e88efca15 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:08:04 +0100 Subject: [PATCH 10/13] fix(rulesets): count the throttle streak in repos, not calls (#787) The backoff added in 34a5404e could not fire on the wall it was written for. `throttled=0` sat immediately after the repo metadata GET, but at the quota wall a repo's GETs still succeed and only the POST is refused: 87 of the 111 UNKNOWN rows in the 2026-09-23 sweep are `POST failed`, against 23 whose metadata read died. Under the old placement that sequence is GET ok -> reset -> POST throttled -> 1 -> GET ok -> reset, so the counter never reaches the limit and the 267-row grind repeats. The streak now counts consecutive REPOS. `note_throttled` marks the current repo, and the reset at the top of the loop body fires only when the previous repo completed without a throttle, so a single blip is still tolerated. The existing fixtures throttled on the metadata read, which sits before the reset, so they were blind to this shape. Adds a POST-throttle triple with healthy GET fixtures, `.rc` handling in the shim's non-GET branch (it had none), and a mutant that silences repo_throttled -- the defect verbatim -- which dies on the repo beyond the wall being reached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- scripts/apply-protection-floor.sh | 10 +++++- scripts/tests/protection-floor-test.sh | 43 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index f3de1ab5e..7d6217726 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -151,6 +151,7 @@ THROTTLE_LIMIT=3 throttled=0 note_throttled() { # repo what-was-read throttled=$((throttled + 1)) + repo_throttled=1 report "$1" "UNKNOWN" "$2 throttled; skipped rather than recorded" if [ "$throttled" -ge "$THROTTLE_LIMIT" ]; then report "-" "ABORTED" "$THROTTLE_LIMIT consecutive throttled reads: the quota window is spent, so every remaining repo would report UNKNOWN. Re-run after the reset -- trust the X-RateLimit-Reset header, not gh api rate_limit, which has reported 5000 remaining against a header saying 0." @@ -169,6 +170,14 @@ printf 'repo\tstate\tdetail\n' printf '%s\n' "$TARGETS" | while IFS= read -r repo; do [ -n "$repo" ] || continue + # The streak counts consecutive REPOS, not consecutive CALLS. At the wall a repo's GETs + # can still succeed while only its POST is refused -- 87 of the 111 UNKNOWN rows in the + # 2026-09-23 run were exactly that shape -- so resetting on any successful read pins the + # count at 1 forever and the guard never fires. Reset only when the PREVIOUS repo + # completed without being throttled at all. + if [ "${repo_throttled:-0}" -eq 0 ]; then throttled=0; fi + repo_throttled=0 + # 1. D50 FIRST, before any read. A vault must not even be a candidate. if is_vault "$repo"; then report "$repo" "EXCLUDED-D50" "gcrypt vault: force-pushes every sync; non_fast_forward would stop the backup" @@ -182,7 +191,6 @@ printf '%s\n' "$TARGETS" | while IFS= read -r repo; do report "$repo" "UNKNOWN" "repos/$repo read failed: ${err%%$'\n'*}" continue fi - throttled=0 # a read got through; the window is not the problem [ -n "$meta" ] || { report "$repo" "UNKNOWN" "repos/$repo returned empty"; continue; } if [ "$(printf '%s' "$meta" | jq -r '.archived')" = "true" ]; then report "$repo" "ARCHIVED" "ruleset POST 403s on an archived repo; unarchive/write/re-archive is a separate, explicit act" diff --git a/scripts/tests/protection-floor-test.sh b/scripts/tests/protection-floor-test.sh index 24d0b269b..d3b8ef70e 100755 --- a/scripts/tests/protection-floor-test.sh +++ b/scripts/tests/protection-floor-test.sh @@ -49,6 +49,8 @@ if [ "$method" != "GET" ]; then printf '%s\t%s\t%s\n' "$method" "$path" "$stdin_body" >> "$GH_FIX/PUTS.log" F="$GH_FIX/${method}_${KEY}" [ -r "$F" ] || { echo "no fixture for $method $path" >&2; exit 1; } + # A write can be throttled while every GET still succeeds -- the dominant wall shape. + if [ -r "$F.rc" ]; then cat "$F" >&2; exit "$(cat "$F.rc")"; fi cat "$F"; exit 0 fi F="$GH_FIX/$KEY" @@ -81,6 +83,17 @@ hyperpolymath/aaa-throttle-2 hyperpolymath/aaa-throttle-3 hyperpolymath/plain-repo EOF + +# A THIRD list: the wall shape that actually happened. Both GETs succeed and only the +# POST is refused (87 of the 111 UNKNOWN rows on 2026-09-23), so a streak that resets on +# any successful read never reaches the limit and the backoff is decorative. +THRP="$WORK/post-throttle-repos.txt" +cat > "$THRP" <<'EOF' +hyperpolymath/aab-postthrottle-1 +hyperpolymath/aab-postthrottle-2 +hyperpolymath/aab-postthrottle-3 +hyperpolymath/plain-repo +EOF cat > "$REPOS" <<'EOF' hyperpolymath/memory-vault hyperpolymath/plain-repo @@ -184,6 +197,20 @@ J echo 1 > "$FIX/repos_hyperpolymath_aaa-throttle-2.rc" echo 1 > "$FIX/repos_hyperpolymath_aaa-throttle-3.rc" + # The POST-throttle triple: full, healthy GET fixtures, and only the write refused. + mkrepo hyperpolymath/aab-postthrottle-1 main false + mkrepo hyperpolymath/aab-postthrottle-2 main false + mkrepo hyperpolymath/aab-postthrottle-3 main false + echo '[]' > "$FIX/repos_hyperpolymath_aab-postthrottle-1_rulesets" + echo '[]' > "$FIX/repos_hyperpolymath_aab-postthrottle-2_rulesets" + echo '[]' > "$FIX/repos_hyperpolymath_aab-postthrottle-3_rulesets" + printf 'HTTP 403: You have exceeded a secondary rate limit.\n' > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-1_rulesets" + printf 'HTTP 403: You have exceeded a secondary rate limit.\n' > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-2_rulesets" + printf 'HTTP 403: You have exceeded a secondary rate limit.\n' > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-3_rulesets" + echo 1 > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-1_rulesets.rc" + echo 1 > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-2_rulesets.rc" + echo 1 > "$FIX/POST_repos_hyperpolymath_aab-postthrottle-3_rulesets.rc" + mkrepo hyperpolymath/nosourcetype-repo main false echo '[{"id":40,"target":"branch","enforcement":"active"}]' \ > "$FIX/repos_hyperpolymath_nosourcetype-repo_rulesets" @@ -264,6 +291,15 @@ check "third consecutive throttle aborts" "ABORTED" "$(state "$OUTT" -)" check "the repo beyond the wall is not reported" "" "$(state "$OUTT" hyperpolymath/plain-repo)" check "an aborted sweep writes nothing" "0" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" +# The same wall, POST-side. The shim logs a write attempt BEFORE it consults the +# fixture, so "wrote nothing" cannot be a line count here: assert instead that the repo +# beyond the wall was never reached at all. +build_fixtures +OUTP="$(run "$SUT" --repos "$THRP" --apply)" +check "third consecutive POST throttle aborts" "ABORTED" "$(state "$OUTP" -)" +check "no repo beyond a POST wall is reported" "" "$(state "$OUTP" hyperpolymath/plain-repo)" +check "no write reached the repo beyond the wall" "0" "$(command grep -c 'plain-repo' "$FIX/PUTS.log")" + echo "== refusals ==" build_fixtures OUT2="$(GH_FIX="$FIX" PATH="$BIN:$PATH" bash "$SUT" --repos /dev/null 2>&1)" @@ -362,5 +398,12 @@ mutant "throttle backoff removed" \ 's/^ exit 3$/ throttled=0/' \ wrote "plain-repo" "$THR" +# The reset-placement defect, verbatim: clearing the streak on any successful read means +# a repo whose GETs pass and whose POST is refused never accumulates one. Silencing +# repo_throttled reinstates exactly that, and the sweep grinds past the wall. +mutant "throttle streak counts calls, not repos" \ + 's/^ repo_throttled=1$/ :/' \ + wrote "plain-repo" "$THRP" + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" [ "$FAIL" -eq 0 ] From 09d0e5a21d319bfb1da1c470e0bb3fd494faa069 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:08:47 +0000 Subject: [PATCH 11/13] docs(protection-floor): clarify throttling helper behavior --- scripts/apply-protection-floor.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index 7d6217726..fc6d6a067 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -135,6 +135,7 @@ TARGETS="$(command grep -vE '^[[:space:]]*(#|$)' "$REPOS_FILE" | tr -d ' \t' | s # plan without rulesets returns. Only the body text separates them, so throttling must # be classified FIRST: matching *403* alone records a throttled repo as PLAN-EXCLUDED # ("private repo / plan limit"), which silently UNDER-REPORTS the protection gap. +# Return success when an API error message identifies throttling. is_throttled() { case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in *"rate limit"*|*"rate-limit"*|*"abuse"*|*"retry-after"*|*"http 429"*) return 0 ;; @@ -149,7 +150,8 @@ is_throttled() { # succeeds clears it. THROTTLE_LIMIT=3 throttled=0 -note_throttled() { # repo what-was-read +# Report a throttled operation and exit with status 3 at the consecutive-throttle limit. +note_throttled() { # repo operation throttled=$((throttled + 1)) repo_throttled=1 report "$1" "UNKNOWN" "$2 throttled; skipped rather than recorded" From 945cc382e57889c65b15851dca7e843fd7805b32 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:59:22 +0000 Subject: [PATCH 12/13] fix(protection): Validate inherited org rulesets Cache only valid ruleset bodies, require default-branch coverage, and report throttled reads once. Add regression coverage for cache retries, branch scope, and throttling. --- scripts/apply-protection-floor.sh | 18 ++++++++-- scripts/tests/protection-floor-test.sh | 50 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index fc6d6a067..e83a1a93d 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -274,10 +274,20 @@ EOF [ -n "$rid" ] || continue cache="$ORG_CACHE/$rid" if [ ! -s "$cache" ]; then - gh api "repos/$repo/rulesets/$rid" > "$cache" 2>/dev/null || : + if org_body="$(gh api "repos/$repo/rulesets/$rid" 2>"$TMPDIR_ERR")" && + printf '%s' "$org_body" | jq -e '.rules | type == "array"' >/dev/null 2>&1; then + printf '%s' "$org_body" > "$cache" + else + err="$(cat "$TMPDIR_ERR" 2>/dev/null)" + if is_throttled "$err"; then note_throttled "$repo" "org ruleset $rid"; fi + fi fi if [ ! -s "$cache" ]; then org_read_ok=0; break; fi - union_org="$union_org,$(jq -r '[.rules[].type] | sort | join(",")' "$cache")" + if jq -e --argjson w "$WANT_INCLUDE" \ + '([.conditions.ref_name.exclude[]?] | length) == 0 and + any(.conditions.ref_name.include[]?; . == $w[0])' "$cache" >/dev/null 2>&1; then + union_org="$union_org,$(jq -r '[.rules[].type] | sort | join(",")' "$cache")" + fi b="$(jq -r '[.bypass_actors[]?] | length' "$cache")" [ "$b" -gt "$org_byp_max" ] && org_byp_max="$b" done </dev/null; } # Extract one repository's state from tab-separated report output. state() { printf '%s\n' "$1" | awk -F'\t' -v r="$2" '$1==r{print $2}'; } +# Count the report rows for one repository. +rows() { printf '%s\n' "$1" | awk -F'\t' -v r="$2" '$1==r{n++} END{print n+0}'; } # Recreate the fixture directory and initialize an empty write log. reset_fix() { rm -rf "$FIX"; mkdir -p "$FIX"; : > "$FIX/PUTS.log"; } @@ -94,6 +96,13 @@ hyperpolymath/aab-postthrottle-2 hyperpolymath/aab-postthrottle-3 hyperpolymath/plain-repo EOF +ORG_EDGE="$WORK/org-edge-repos.txt" +cat > "$ORG_EDGE" <<'EOF' +metadatastician/org-cache-invalid-a +metadatastician/org-cache-retry-b +metadatastician/org-release-only +metadatastician/org-throttled +EOF cat > "$REPOS" <<'EOF' hyperpolymath/memory-vault hyperpolymath/plain-repo @@ -242,6 +251,38 @@ J echo '{"id":9061}' > "$FIX/POST_repos_metadatastician_org-halffloor-repo_rulesets" echo '[{"type":"deletion"},{"type":"non_fast_forward"}]' \ > "$FIX/repos_metadatastician_org-halffloor-repo_rules_branches_main" + + # A successful response without a rules array must not poison the org cache. The next + # repo inherits the same org ruleset id and must retry the read rather than reuse it. + mkrepo metadatastician/org-cache-invalid-a main false + mkrepo metadatastician/org-cache-retry-b main false + echo '[{"id":62,"source_type":"Organization","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_metadatastician_org-cache-invalid-a_rulesets" + cp "$FIX/repos_metadatastician_org-cache-invalid-a_rulesets" \ + "$FIX/repos_metadatastician_org-cache-retry-b_rulesets" + echo '{"id":62,"message":"rules temporarily unavailable"}' \ + > "$FIX/repos_metadatastician_org-cache-invalid-a_rulesets_62" + cat > "$FIX/repos_metadatastician_org-cache-retry-b_rulesets_62" <<'J' +{"id":62,"rules":[{"type":"deletion"},{"type":"non_fast_forward"}], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},"bypass_actors":[]} +J + + # Both floor rules on release branches do not cover the default branch. + mkrepo metadatastician/org-release-only main false + echo '[{"id":63,"source_type":"Organization","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_metadatastician_org-release-only_rulesets" + cat > "$FIX/repos_metadatastician_org-release-only_rulesets_63" <<'J' +{"id":63,"rules":[{"type":"deletion"},{"type":"non_fast_forward"}], + "conditions":{"ref_name":{"include":["refs/heads/release/*"],"exclude":[]}},"bypass_actors":[]} +J + + # A throttled org-body read is classified by note_throttled and reported once. + mkrepo metadatastician/org-throttled main false + echo '[{"id":64,"source_type":"Organization","target":"branch","enforcement":"active"}]' \ + > "$FIX/repos_metadatastician_org-throttled_rulesets" + printf 'HTTP 403: API rate limit exceeded for user ID 12345.\n' \ + > "$FIX/repos_metadatastician_org-throttled_rulesets_64" + echo 1 > "$FIX/repos_metadatastician_org-throttled_rulesets_64.rc" } echo "== report mode (no --apply) ==" @@ -263,6 +304,15 @@ check "complete org cover is ORG-INHERITED" "ORG-INHERITED" "$(state "$OUT" me check "HALF org cover is not a cover" "WOULD-CREATE" "$(state "$OUT" metadatastician/org-halffloor-repo)" check "report mode writes nothing" "0" "$(wc -l < "$FIX/PUTS.log" | tr -d ' ')" +echo "== org cache and scope edges ==" +build_fixtures +OUTE="$(run "$SUT" --repos "$ORG_EDGE")" +check "invalid org body is UNKNOWN" "UNKNOWN" "$(state "$OUTE" metadatastician/org-cache-invalid-a)" +check "invalid org body is not cached" "ORG-INHERITED" "$(state "$OUTE" metadatastician/org-cache-retry-b)" +check "release-only org floor does not cover default" "WOULD-CREATE" "$(state "$OUTE" metadatastician/org-release-only)" +check "throttled org body is UNKNOWN" "UNKNOWN" "$(state "$OUTE" metadatastician/org-throttled)" +check "throttled org body is reported once" "1" "$(rows "$OUTE" metadatastician/org-throttled)" + echo "== apply mode ==" build_fixtures OUT="$(run "$SUT" --repos "$REPOS" --apply)" From a889610583265a51a349fbd8cfbaf23264a71824 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:33:22 +0100 Subject: [PATCH 13/13] Update scripts/apply-protection-floor.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/apply-protection-floor.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/apply-protection-floor.sh b/scripts/apply-protection-floor.sh index e83a1a93d..a3c3a5578 100755 --- a/scripts/apply-protection-floor.sh +++ b/scripts/apply-protection-floor.sh @@ -279,7 +279,12 @@ EOF printf '%s' "$org_body" > "$cache" else err="$(cat "$TMPDIR_ERR" 2>/dev/null)" - if is_throttled "$err"; then note_throttled "$repo" "org ruleset $rid"; fi + if [ ! -s "$cache" ]; then + if tmpb="$(gh api "repos/$repo/rulesets/$rid" 2>"$TMPDIR_ERR")" \ + && printf '%s' "$tmpb" | jq -e '.rules | type == "array"' >/dev/null 2>&1; then + printf '%s' "$tmpb" > "$cache" + elif is_throttled "$(cat "$TMPDIR_ERR" 2>/dev/null)"; then + note_throttled "$repo" "org ruleset $rid"; org_read_ok=0; break fi fi if [ ! -s "$cache" ]; then org_read_ok=0; break; fi