Skip to content

Catch packages leaving an enforced rule and new ratcheted rules, and give each downgrade an open tracking issue - #913

Merged
anth-volk merged 12 commits into
mainfrom
rule-weakening-guard
Oct 26, 2026
Merged

anth-volk merged 12 commits into
mainfrom
rule-weakening-guard

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #912

Same-Repository Draft PR Check

  • This PR is a draft.
  • This PR is from a branch in PolicyEngine/policyengine-sim-api, not a personal fork.

Summary

Stacked on #877. The rules guard (python -m scripts.rules guard, run in CI by scripts/run_quality_guards.py) compares rules/registry.toml with the base branch's registry. Before this PR it failed when a baseline count grew (a ratcheted rule's baseline is the file of violations it still tolerates) and when a rule's own status weakened without [rule.downgrade] reason. This PR closes the gaps that #877's type-checking guide listed, and makes every downgrade name an open tracking issue:

  1. Packages that leave a rule. A package that an enforced or ratcheted Pyright rule lists on the base branch, and whose pyproject.toml still exists, must stay in a Pyright rule at least as strong: enforced for a package from an enforced rule, enforced or ratcheted for one from a ratcheted rule. Otherwise the guard fails, unless the rule that lists the package now has [rule.downgrade]. A package that no rule lists fails too. Moving a package to a rule at least as strong, or deleting it, passes.
  2. New ratcheted rules. A rule the base branch doesn't have, that is ratcheted with a baseline count above 0, needs [rule.downgrade].
  3. Tracking issues. [rule.downgrade] now needs issue, next to a non-empty reason. The loader always checks that issue is a positive whole number (TOML's true is rejected). On every run, the guard looks up every section's issue with gh api, through the PR description check's lookup, and fails unless it is an open issue (not a pull request) in issue_repository.
    • On GitHub Actions, the guard fails when neither GH_TOKEN nor GITHUB_TOKEN is set, when a lookup fails, when gh can't run, and when issue_repository isn't the repository the workflow runs in (GITHUB_REPOSITORY).
    • Outside GitHub Actions, without a token, it prints a note for each issue it didn't check. scripts/run_quality_guards.py now prints the rules guard's notes, as it already printed the ci-scripts guard's. With a token, it behaves as in CI.
  4. CI. The quality-guards job in .github/workflows/pr.yml now has permissions: contents: read, issues: read. Only its "Run quality guards" step gets GH_TOKEN: ${{ github.token }}.
  5. Docs. docs/engineering/skills/type-checking.md (the guard's checks, the "It doesn't check" list and the paragraph after it) and docs/engineering/skills/rules-registry.md (the [rule.downgrade] row, the Baselines bullets, and the commands paragraph) describe what the guard now checks, and what it still doesn't.

The registry has no [rule.downgrade] section on this branch, pyright-apis-integ or main, so nothing in the registry changes status. The only registry change is the new top-level issue_repository = "PolicyEngine/policyengine-sim-api". python3 -m scripts.rules render leaves docs/generated/rules.md and AGENTS.md unchanged.

Design choices

  • Field names. [rule.downgrade] keeps reason and gains issue, the name [[rule.exception]] already uses for its tracking issue. The parsed section is Downgrade(reason, issue), which replaces Rule.downgrade_reason. A section without both is reported, and read as absent, so it never allows a weaker rule. Unknown keys in it are now reported.
  • Which enforcer kinds the package comparison covers: Pyright only. It is the only kind with projects; the schema rejects that field on every other kind. A package that moves between two enforced rules keeps its check only if both run the same checker, so a comparison across kinds would be wrong anyway.
  • Which [rule.downgrade] sections are looked up: all of them, on every run, not only those this pull request adds. The invariant is then "each downgrade names an open issue". The cost is that closing the issue while its section is still in the registry fails the guard on every pull request, as an expired review_by date does. So that restoring a rule doesn't cause this, the schema now rejects [rule.downgrade] on an enforced rule: the pull request that restores the rule must remove the section, and can then close the issue.
  • Where the repository comes from. The new top-level issue_repository key means local runs and CI look issues up in the same repository; the existing repo key holds only the name. On GitHub Actions it must match the runner's GITHUB_REPOSITORY, which a pull request can't change.
  • Reusing Check that every PR description starts with Fixes #N for an open issue #862's lookup. check_pr_description.py's lookup raised DescriptionError with messages that end in how to fix the description. require_open_issue now raises NotAnOpenIssueError (missing, moved or closed issue, or a pull request) or IssueLookupError (GitHub couldn't be asked, or its answer wasn't an issue), with messages that say only what is wrong. main() adds the same fixes as before. The guard loads the script by path and adds its own fixes. The PR description check's messages and exit codes are unchanged, except that the two lookup failures that said only "Re-run this job." now also say to check the job's GH_TOKEN, as the third already did.
  • Locally, without a token. The guard counts GH_TOKEN or GITHUB_TOKEN as a token, since gh reads both. With neither, outside GitHub Actions, it skips the lookup and adds a note per issue: rule <id>: tracking issue #<n> was not checked, because neither GH_TOKEN nor GITHUB_TOKEN is set. CI checks it; to check it here, set one, for example GH_TOKEN="$(gh auth token)". With a token, a lookup that fails locally fails the guard, as in CI.

Commits

Each commit's tests were written first and run against the old code, or, where the module didn't exist yet, against a stub that returns no findings, so each test fails on its own assertion. The rules tool's tests: python3 -m unittest discover -s scripts/rules/tests -t ..

  1. Require an issue number in every [rule.downgrade] (f731679b). Red: test_load couldn't import Downgrade; a reason-only downgrade still passed (test_downgrade_needs_reason_and_issue); the status fix text was the old one. That is 1 failure and 2 errors, out of 188 tests. Green: 195 tests pass.
  2. Fail when a package leaves an enforced Pyright rule without a downgrade (608c6e45). Red, against a stub: 5 tests failed (6 failures; one test covers both ratcheted and advisory), and the 5 tests of allowed cases passed. Green: 205 tests pass.
  3. Require a downgrade for a new rule that starts ratcheted (b0bd4f89). Red: 2 failures; a new ratcheted rule with 2 tolerated violations passed without a downgrade, and no note named the downgrade. The count-0 test passed. Green: 208 tests pass.
  4. Separate the issue lookup from the PR description's fix text (a0882ae7). Red: 8 new in-process tests of require_open_issue failed with AttributeError, and the 52 existing PR-description tests passed. Green: 60 pass; the whole simulation-entry suite, 738 tests, passes.
  5. Check that each [rule.downgrade] names an open tracking issue (8ae08ffc). Red, against a stub: 17 tests, 22 failures counting subtests. Most failed first on the loader rejecting issue_repository as an unknown key. A second red: a lookup script without require_open_issue crashed the guard with AttributeError. Green: 227 tests pass.
  6. Give the quality guards a token that can read issues (94d57f7f). Red: 2 failures; the job had no permissions (KeyError), and no step got GH_TOKEN. Green: both pass, along with the 222 PR-description, workflow-script and deployment-asset tests, and the 10 stacked-CI tests.

Commits from the self-review:

  1. Hold a ratcheted rule's packages to a rule at least as strong (29467523). Red: 2 failures; a package moving from a ratcheted rule to an advisory rule passed, and so did a package from a ratcheted rule that no rule lists. Green: 230 tests pass.
  2. Reject true as an exception's issue number (201a2c90). Red: 1 subtest failure, issue = true. Green: 231 tests pass.
  3. Name the token variable gh used when a lookup fails (67a51051). Red: 1 failure; with only GITHUB_TOKEN set, the fix said to check GH_TOKEN. Green: 232 tests pass.
  4. Keep the tracking-issue schema tests away from the real gh (4e481bd5). Red: with a token set and a gh on PATH that records its calls, RegistrySchemaTest ran gh api repos/example/repo/issues/912. Green: the same run records no call, and the whole suite run that way records none.
  5. Look tracking issues up only in the repository the workflow runs in (90e77bf5). Red: 1 failure; with GITHUB_REPOSITORY=example/other, the guard still looked the issue up in the registry's example/repo and passed. Green: 234 tests pass, both locally and with GITHUB_ACTIONS=true, GITHUB_REPOSITORY set and no token.
  6. Say what the guard still doesn't check about downgrades (795c73af). Docs and one test comment only; 234 tests pass.

Suppressions added

None. The diff adds no # noqa, # type: ignore or # pyright: ignore. Where ruff objected, the code changed instead: run_guard's base comparison moved into _compare_with_base (C901, PLR0912), a constant was renamed (S105), and an annotation-only import moved under TYPE_CHECKING (TC001).

Real bugs found

  • A ratcheted rule's packages could leave CI without a downgrade. A package could move from a ratcheted Pyright rule into a new advisory rule with no [[rule.ci]], and then leave the pyright job's matrix, with only notes. Fixed in commit 7.
  • The pull request under review decided which repository the issue had to be in. Fixed in commit 11.
  • [[rule.exception]] issue = true was accepted as issue 1 (this predates the branch). Fixed in commit 8.
  • A rules test called the real gh whenever a token was set in the shell. It made a read-only request for example/repo, which the test then ignored. Fixed in commit 10.

Self-review

Self-review, not a review disposition. Two rounds, using the review-code format: the first by the agent that wrote the change, the second by a separate reviewer that was given the code and the plan but not the first round's findings.

Round 1 found:

  • Bug: a ratcheted rule's packages weren't compared (commit 7).
  • Bug: an exception's issue = true was accepted (commit 8).
  • Code quality: a failed lookup's fix named only GH_TOKEN (commit 9).
  • Code quality: the rules-registry guide's commands paragraph didn't mention gh (commit 9).

Round 2 found:

  • Bug: issue_repository came from the head registry (commit 11).
  • Code quality: test isolation (commit 10).
  • Code quality, three docs statements the code didn't bear out (commit 12):
    • a move to a new ratcheted rule with a count above 0 still needs a downgrade;
    • removing [rule.downgrade] from a rule that stays weakened passes;
    • replacing a rule with a weaker one under a new id produces only notes.
  • Not changed: typing the loaded lookup module with a Protocol. scripts/rules isn't type-checked, and the tests run against the real module, so a rename fails them.
  • Not changed: under Python 3.11, test_pyright.PyrightCommandTest.test_list_packages_takes_no_other_pyright_options fails, because 3.11's argparse rejects the arguments before the command's own check runs. This branch doesn't touch that test or cli; under 3.13 and 3.14 all tests pass.
  • It also confirmed that the PR description check's messages and exit codes are unchanged, apart from the retry text.

Round 3, by the same separate reviewer, covered commits 10–12 (67a51051..795c73af) and found no substantiated bugs. One minor note, not changed: a workflow that runs inside a fork, rather than a pull request from a fork into this repository, would fail whenever the registry has a [rule.downgrade], because its GITHUB_REPOSITORY names the fork. This repository runs CI only for same-repository branches, and forks have Actions turned off by default.

Gaps the guard still has, now listed in the guides:

  • what a reason says, and whether the issue is about restoring this rule;
  • which baseline entries changed;
  • whether a [rule.downgrade] section is new, or was removed from a rule that stays weakened;
  • a rule removed, or replaced under a new id, outside Pyright packages and new ratcheted rules.

Requiring a section to stay while its rule stays weakened would make every downgrade permanent-until-restored, so it is left as a decision for a person.

Environment variables

Variable Read by Provider How it reaches the code
GH_TOKEN gh, run by scripts/rules/tracking_issues.py through check_pr_description.require_open_issue; the guard checks it is set github.token, the job's automatic token, limited by the quality-guards job's new permissions: contents: read, issues: read Step-level env: on "Run quality guards". run_quality_guards.py runs the rules guard in its own process, so os.environ has it, and subprocess.run(["gh", ...]) passes no env=, so gh inherits it. The earlier "Test the rules registry tool" step doesn't get it.
GITHUB_TOKEN (environment variable) the guard, as gh's second token variable not set in CI: the runner doesn't export it, and no workflow sets it; locally, whatever the shell sets —
GITHUB_ACTIONS tracking_issues.check_tracking_issues (and the existing base-ref handling in guard.py) the GitHub Actions runner, true in every step runner environment
GITHUB_REPOSITORY tracking_issues.check_tracking_issues (new); check_pr_description.main() (unchanged, only when run as the PR description job's script) the GitHub Actions runner runner environment
GITHUB_BASE_REF baseline.default_base_ref (unchanged) the runner, on pull_request events runner environment
PATH (for gh) subprocess.run(["gh", ...]) GitHub-hosted ubuntu-latest image gh is preinstalled; the PR description job already relies on it, and passed on this PR

The tests set their own FAKE_GH_CALLS, FAKE_GH_ANSWERS and FAKE_CALLS_LOG, and clear GH_TOKEN, GITHUB_TOKEN, GITHUB_ACTIONS and GITHUB_REPOSITORY where they matter.

Not verified in CI:

  • This PR is above the bottom of stack #867, so pr.yml's jobs are skipped here (CI complete fails by design). quality-guards will first run with these changes once Fix apis-integ's Pyright errors and enforce every Pyright rule #877 merges.
  • Because the registry has no [rule.downgrade] section, that run won't call the lookup, so the token's live use is unexercised.
  • The new job-level permissions drop every scope but contents and issues. Other jobs here already run actions/checkout, setup-python@v6 and setup-uv@v8.1.0 with only contents: read (publish-clients.yml, simulation-deploy.reusable.yml), so I expect this job to work, but it hasn't run yet.

Testing

Run before each push, from the repository root:

  • python3 -m unittest discover -s scripts/rules/tests -t .: 234 tests pass. They also pass with GITHUB_ACTIONS=true, GITHUB_REPOSITORY set and no token, as in the CI step, and with tokens set and a recording gh first on PATH, which records no call.
  • python3 scripts/run_quality_guards.py: every guard passes. python3 -m scripts.rules guard --base-ref origin/pyright-apis-integ and --base-ref origin/main pass.
  • Ruff 0.15.22 (run with uv tool run ruff@0.15.22, because the local ruff is 0.16.8): format --check projects/*/src libs/*/src and check . pass, and so does format --check scripts .github/scripts.
  • python3 -m scripts.rules pyright --sync: every package passes with no Pyright errors. Pyright doesn't check scripts/ or .github/scripts/, so its results can't change here. For 4e481bd5, a test-only change, I ran Pyright straight after pushing rather than before.
  • simulation-entry, with uv run --frozen pytest: tests/test_pr_description_check.py (60) and tests/test_quality_guards_workflow.py (2) pass. The whole suite (738) passed after commit 4. simulation-executor's tests/test_ci_complete_gate.py (10) passes.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #867 October 26, 2026 13:49
@anth-volk anth-volk changed the title Require an issue number in every [rule.downgrade] Catch packages leaving an enforced rule and new ratcheted rules, and give each downgrade an open tracking issue Oct 26, 2026
Base automatically changed from pyright-apis-integ to main October 26, 2026 17:56
anth-volk and others added 12 commits October 26, 2026 21:56
A [rule.downgrade] section now needs `issue`, the number of the issue
that tracks restoring the rule, next to a non-empty `reason`. The loader
reports an issue that isn't a positive whole number (including TOML's
`true`), an empty reason, a section that isn't a table and unknown keys,
and reads such a section as absent, so it never allows a weaker rule.

The parsed section becomes a Downgrade(reason, issue) in place of
Rule.downgrade_reason. The guard's note for a downgrade names the
issue, and its fix for a weakened status says that only a person may
add the section.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard compared each rule's status with the base branch, but
not the packages a Pyright rule lists. Moving a package out of an
enforced rule into a new ratcheted or advisory rule passed, because a
new rule has no status on the base branch to compare with.

check_enforced_packages now holds each package that an enforced Pyright
rule lists on the base branch, and whose pyproject.toml still exists,
to an enforced Pyright rule on the head. Moving it to another enforced
rule, or deleting it, passes. Moving it to a rule that isn't enforced
needs [rule.downgrade] on that rule; listing it in no rule fails. A rule
that keeps the package under the same id is left to the status
comparison, so a ratcheted rule is reported once.

Only Pyright enforcers list packages: the schema rejects `projects` on
every other kind, and a package that moves between two enforced rules
keeps its check only if both run the same checker.

The base comparison moves out of run_guard into _compare_with_base, to
keep run_guard within ruff's complexity limits.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rule the base branch doesn't have produced only the note "rule ... is
new", so a new rule could start ratcheted, with a baseline of tolerated
violations, and no [rule.downgrade]. Such a rule is weaker than an
enforced one from the start.

check_baselines now fails a new ratcheted rule whose baseline count is
above 0 unless it has [rule.downgrade], and notes the reason and issue
when it has one. A new ratcheted rule with a count of 0 passes.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_pr_description.py's lookup raised DescriptionError with messages
that end in how to fix the description, so another check couldn't reuse
it. require_open_issue now looks an issue up on its own: it raises
NotAnOpenIssueError when the number names a missing, moved or closed
issue or a pull request (with the issue's state when it is a closed
issue here), and IssueLookupError when GitHub can't be asked or its
answer isn't an issue. Their messages say only what is wrong.

main() adds the same fixes as before, so the PR description check's
messages don't change, except that the two lookup failures that said
only "Re-run this job." now also say to check the GH_TOKEN the workflow
gives the job, as the third one already did.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard now looks up the issue of every [rule.downgrade] in the
registry on every run (scripts/rules/tracking_issues.py), with the PR
description check's require_open_issue, which runs gh api. A number
that names a missing, moved or closed issue, or a pull request, fails.

gh reads its token from GH_TOKEN or GITHUB_TOKEN. On GitHub Actions,
neither being set, a lookup that fails, gh not running, or a lookup
script that doesn't load or define the lookup, fails the guard. Outside
GitHub Actions, without a token, the guard notes each issue it didn't
check instead; with a token, it fails as in CI.
scripts/run_quality_guards.py now prints the rules guard's notes, as it
already did the ci-scripts guard's, so that note is visible there.

The registry names the repository with a new top-level key,
issue_repository, so local runs and CI look issues up in the same
place. A downgrade in a registry without it, and a downgrade on an
enforced rule, are schema violations. The second keeps the issue's
lifecycle consistent: the pull request that restores a rule must remove
its section, so closing the issue afterwards doesn't fail the guard on
every later pull request.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rules guard fails on GitHub Actions when it can't look up a
downgrade's tracking issue, so the quality-guards job now gives the
step that runs scripts/run_quality_guards.py the job's token as
GH_TOKEN, which gh reads. The guards run in that process, and gh
inherits its environment.

The job's permissions are now only contents: read, for the checkout,
and issues: read, for the lookup. The rules tool's tests run in an
earlier step without the token.

test_quality_guards_workflow.py pins both: the job's permissions, and
that only the guard step gets the token.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package comparison skipped every base rule that wasn't enforced. A
package could move from a ratcheted Pyright rule into a new advisory
rule with no [[rule.ci]], leave the pyright job's matrix and stop being
type-checked in CI, with only the note "rule ... is new". The status
comparison already treats the same change of a whole rule, ratcheted
to advisory, as a downgrade.

check_enforced_packages becomes check_pyright_packages. A package from
a ratcheted rule must now stay in an enforced or ratcheted Pyright
rule, unless the rule that lists it now has [rule.downgrade]. A move
between ratcheted rules passes here; check_baselines compares the
counts. Both comparisons share _weakens, so they agree on which status
changes weaken a rule.

Found in the self-review.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
[[rule.exception]] issue accepted TOML's `true`, which Python reads as
a bool and so as the int 1, although the loader's message says the
field must be a positive issue number. It now uses the same
_issue_number check as [rule.downgrade] issue, which rejects a bool.

Found in the self-review; the behaviour predates this branch.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix for a failed tracking-issue lookup always told the reader to
check GH_TOKEN, but gh also reads GITHUB_TOKEN, and the guard looks the
issue up when only that one is set. The fix now names the variable the
token came from, the first of GH_TOKEN and GITHUB_TOKEN that is set,
as gh picks it.

The rules-registry guide's commands paragraph said only that the
commands need nothing beyond the standard library; it now also says
that the guard runs gh api for a [rule.downgrade].

Found in the self-review.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RegistrySchemaTest runs the whole rules guard on registries that have a
[rule.downgrade], but it didn't put the fake gh first on PATH or clear
the token variables. With GH_TOKEN or GITHUB_TOKEN set, as in a shell
where gh is logged in, test_an_enforced_rule_has_no_downgrade ran gh api
for issue 912 in example/repo; a gh on PATH that records its calls
showed the call. The class now uses TrackingIssueTestCase, like the
other lookup tests, and the same run records no call.

Found in the self-review.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard took the repository to look a downgrade's issue up in from
issue_repository in the registry under review, so a pull request could
change that one line to another public repository and name an open
issue there. On GitHub Actions the runner sets GITHUB_REPOSITORY, which
a pull request can't change; when it is set and differs from
issue_repository (ignoring case), the guard now fails before it looks
anything up. Outside GitHub Actions the registry's value is used.

The test case that isolates the lookup also clears GITHUB_REPOSITORY,
which the runner sets in the step that runs the rules tests.

Found in the self-review.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The self-review found three statements in the type-checking and
rules-registry guides that the code doesn't bear out:

- Moving a package to a rule at least as strong passes the package
  comparison, but a new ratcheted rule with a count above 0 still needs
  [rule.downgrade]. The guides, and a test comment, said the move
  passes.
- Closing the tracking issue fails the guard only while the
  [rule.downgrade] section is in the registry. Removing the section
  from a rule that stays ratcheted or advisory passes, so the issue can
  then be closed without failing anything. The type-checking guide now
  lists that under "It doesn't check".
- The guard compares rules by id, so removing a rule or replacing it
  with a weaker rule under a new id produces only notes, apart from
  Pyright packages and new ratcheted rules. The rules-registry guide
  now says so.

Refs #912

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the rule-weakening-guard branch from 795c73a to 66a940b Compare October 26, 2026 17:56
@anth-volk
anth-volk marked this pull request as ready for review October 26, 2026 18:23
@anth-volk
anth-volk requested a review from a team as a code owner October 26, 2026 18:23
@anth-volk
anth-volk merged commit eefe53a into main Oct 26, 2026
22 checks passed
@anth-volk
anth-volk deleted the rule-weakening-guard branch October 26, 2026 18:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the rules guard catch packages leaving an enforced rule and new ratcheted rules, and give each downgrade an open tracking issue

1 participant