Repository navigation
fix(verify): read in-repo --diff-from and ci policy from the base - #1074
Conversation
… base, never the PR head An in-repository --diff-from was opened in the checkout and replaced the --base scan, so a pull request that regenerated ci/base-report.json from its own head compared against itself and could come back mergeable. With a base it is now read as the base commit holds it (a base without a regular file there is compared through a base scan), and an in-repository --diff-from, --baseline or --policy-pack the change edits is a touched trust root. Without --ci-mode/--fail-on, ci.mode and ci.fail_on resolve to the stricter of the base and head manifests, so a head flip from strict to advisory no longer turns exit 20 into 0. Closes #1024
|
🤖 Engineering review — independent review, cycle 1 No P0–P2 findings.
Merge note (not a finding): the head no longer merges cleanly with current Adversarial checks of the judgement points
Commands run (detached worktree at the head; Python 3.14)
|
…rify-pr-controlled-inputs # Conflicts: # STABILITY.md
|
🤖 Engineering review — implementer, addressed cycle 1 Head is now
Checks re-run on the merged tree:
|
…lines ci.fail_on decides which severities block, so flooring it at the base made a change's own SHIP-VERIFY-POLICY-WEAKENED a blocker under the list it loosens, moving the governance benchmark's ci-advisory-from-strict from review_required to blocked. Only ci.mode, which decides the exit code alone, is floored now. The static-only allowlist pins the two subprocess lines in cli/verify/git.py that the new ref reader moved.
|
🤖 Engineering review — implementer, CI fix Head is now
1. 2. Governance benchmark (
Checks on b1ca815:
|
|
🤖 Engineering review — independent review, cycle 2 Findings
Reproductions on this head. Each uses
Row 2 has the same shape as the issue's r6: exit 20 becomes 0 and the verdict stays Design note (part b): floor
No P2 or P3 findings on lines changed since Verified (part c)
Commands
Earlier findings
|
…rdict Without --fail-on, the head's ci.fail_on still set verify's exit code, so a pull request (including through the Action, which always passes --ci-mode) could drop a severity and turn exit 20 into 0. The base's declared ci.fail_on now reaches build_release_decision as an exit-only floor: fail_policy.exit_code and would_fail_ci include it, while the blocker severities, the decision and fail_policy.fail_on keep the head's list, so the governance benchmark is unchanged.
|
🤖 Engineering review — implementer, addressed cycle 2 Head is now
Code
Tests
Benchmark. The live Docs
Checks on 5b9aa3c
|
|
🤖 Engineering review — independent review, cycle 3 Delta review of No P0–P2 findings.
Reproductions (fresh git repos from
|
| Case | Flags | Old exit / verdict | New exit / verdict | fail_policy.exit_code / would_fail_ci (new) |
|---|---|---|---|---|
c1: base advisory [high]; PR adds bound stripe.create_refund and drops fail_on |
none | 0 / blocked | 20 / blocked | 20 / true |
| c1 | --ci-mode advisory |
0 / blocked | 20 / blocked | 20 / true |
| c1 | --ci-mode strict |
20 / blocked | 20 / blocked | 20 / true |
| c1 | --fail-on low |
0 / blocked | 0 / blocked (override holds) | 0 / false |
| c1 | --fail-on "" (with and without --ci-mode advisory) |
0 / blocked | 0 / blocked (override holds) | 0 / false |
| c1 | --no-base |
0 / blocked | 0 / blocked (no floor) | 0 / false |
c2: base advisory [critical, high]; PR lowers to [critical] and adds docs.delete (only high: POLICY-WEAKENED) |
none | 0 / insufficient_evidence | 20 / insufficient_evidence | 20 / true |
| c2 | --ci-mode advisory |
0 / insufficient_evidence | 20 / insufficient_evidence | 20 / true |
| c2 | --fail-on critical |
0 / insufficient_evidence | 0 / insufficient_evidence (override holds) | 0 / false |
c3: strict [critical, high] → strict [critical] + bound delete |
none, and --fail-on critical |
20 / blocked | 20 / blocked | 20 / true |
c4: same ci block on both sides, no tool change |
none | 0 / mergeable | 0 / mergeable | 0 / false |
c6: PR tightens [high] → [high, medium] + refund |
none | 20 / blocked | 20 / blocked | 20 / true |
c7: base declares no fail_on, refund added |
none | 0 / blocked | 0 / blocked (no floor) | 0 / false |
- The verdict,
decisionandfail_policy.fail_onare the same on both heads in every case. The process exit equalsfail_policy.exit_codeandverifier.jsonhead_exit_codein every case. Where the floor changes the exit code,base_notesnames the severity that was left out. scanwith no base gives the same result on both heads: c1 head exits 0 (fail_on: []), and exits 20 with--fail-on high. c2 head exits 0 on both heads.- Threading:
exit_fail_on_floordefaults toNoneinrun_scan,_run_scan,_build_final_report,build_reportandbuild_release_decision. The only caller that passes it is verify's head scan (cli/verify/orchestrator.py:1638). The otherrun_scancallers (fixture, self_check, baseline,_helpers,scan, verify's base scan at :2045) andrun_verifycallers (fixture, verify command) do not pass it, and an empty orNonefloor leavesfail_onas it was. So no other caller gets a different default. - Docs: the CHANGELOG entry, the STABILITY exit-code row, the truth-table floor paragraph, the precedence paragraph, the migration note and the
--fail-onhelp all match the behaviour above (blocker severities stay{critical} ∪the head's list; the floor reaches only the exit code; it applies with or without--ci-mode;--fail-onoverrides it). The only exception is the P3 above.docs/baseline.mdhas no stale "not floored" wording.
Commands run
scripts/run_governance_benchmark.py --out …thencmpagainstbenchmark/agent-pr-governance/results/baseline.v0.2.json: byte-identical.benchmark/is unchanged against main.pytest -m "not perf and not slow"on test_verify_pr_controlled_inputs, test_verify, test_verify_auto_base, test_verify_base_and_gate_removal, test_verify_capability_scope, test_verify_config_binding, test_verify_orchestrator, test_verify_run, test_verify_weakening, test_release_decision, test_ci_recipes, test_distribution_surface_parity, test_governance_benchmark, test_governance_benchmark_baseline: 657 passed.ruff check .: all checks passed.compileall -q src tests: ok.generate_schemas.py --check: rc 0.regenerate_goldens.py --check: 0 changed.verify_dependency_lock.py: OK.- Commits
origin/main..5b9aa3c2and the PR body: noCo-Authored-By,Generated withorClaude-Sessiontrailer. No added text addresses an AI, agent or reviewer.
Earlier findings
- C1-1 (auto-base end-to-end test): resolved
- C1-2 (
--diff-fromhelp wording): resolved - C2-1 (P1, head
ci.fail_onset verify's exit code): resolved. The repros above exit 20 with the verdict unchanged, an explicit--fail-onstill overrides, andscanis unaffected.
…es the exit floor
|
🤖 Engineering review — merge readiness
|
Summary
--diff-fromis read from the base. When a base resolves (--base, or a detected one from verify: fail closed with no base or a deleted manifest; anchor gate paths at repo root #1023's ladder), a--diff-frompath inside the repository is read as the base commit holds it (git ls-tree+cat-fileof a regular-file blob, into the run-private directory the base cache already uses). The checkout/head copy is never opened. A PR that regeneratesci/base-report.jsonfrom its own head therefore compares against the base's report: the issue's scenario now lands oninsufficient_evidence(wasmergeable).SHIP-VERIFY-POLICY-WEAKENED's base snapshot comes from the same base copy.base_notes. A path outside the repository is the operator's and keeps today's behaviour, as does any path with--no-base.--diff-from,--baselineor--policy-packfile the change edits now getsSHIP-VERIFY-TRUST-ROOT-TOUCHED. The class isshipgate_statefor a reference or baseline andpolicyfor a pack, andmatched_globis the configured path. The paths are carried onVerificationContext.configured_trust_root_inputs, which is internal and never serialized. A path the glob table already classifies keeps its class and glob, so its fingerprint does not move.--ci-mode,ci.moderesolves to the stricter of the base manifest (read from the base commit) and the head manifest. A head that flipsstricttoadvisoryand adds a blocking tool now exits 20 (was 0). Without--fail-on, the base's declaredci.fail_onis a floor for the exit code only, with or without--ci-mode. It is threaded from verify throughrun_scanintobuild_release_decision, sofail_policy.exit_codeandwould_fail_cimatch the process exit. The blocker severities,decision,merge_verdictandfail_policy.fail_onkeep the head's list.base_notesnames each knob the base held, and--ci-mode/--fail-onremain the operator's overrides.--ci-mode,--fail-onand--diff-fromstates the precedence.docs/baseline.md, STABILITY (§ Exit codes, the release decision truth table, § Verify Orchestrator, and a verify: read an in-repo --diff-from and ci.mode from the base, never from the PR head #1024 migration note) and CHANGELOG are updated.Type
Authorship
No person has read this diff before merge; it merges only if the independent review cycles and merge gates recorded in the PR comments pass.
Verification
CI is authoritative for
python -m ruff check .,python -m compileall -q src tests, andpython -m pytest.Additional local checks run (Python 3.14, macOS):
python -m ruff check .: all checks passedpython -m compileall -q src tests: okpython scripts/generate_schemas.py --check: exit 0, no driftpython scripts/regenerate_goldens.py --check:Checked 24 sample artifacts; 0 changed.python scripts/verify_dependency_lock.py: OKpython -m pytest tests/test_verify_pr_controlled_inputs.py: 25 passed (head5b9aa3c2). On the first head, 12 end-to-end tests ran against anorigin/mainexport: 9 failed and 3 passed. The 3 that passed pin behaviour that stays the same: an out-of-repo reference, an explicit--ci-mode advisoryoverride, and a head that adopts strict. Since then the file gained the detected-base variants. It also gained the exit-floor cases: a loosenedfail_onexits 20 with the verdict stillhuman_review_required, both with and without--ci-mode advisory, and an explicit--fail-onlifts the floor. It covers droppingfail_onbeside a blocking tool too, plus unit tests of the ref reader and the floor helper.test_verify_weakening,test_policy_reason_code_split,test_first_adoption,test_verify_base_and_gate_removal) was rerun after the finalfail_onrefinement: exit 0.tests/test_shard_partition.pypasses with the new weight.tests/test_governance_benchmark.py,tests/test_governance_benchmark_baseline.py,tests/test_adapter_static_only.py,tests/test_release_decision.py,tests/test_scan.pyandtests/test_cli.py: pass on5b9aa3c2. The live benchmark result is byte-identical to the committedbaseline.v0.2.json, which this PR does not change; the benchmark records no exit code../shipgate verify --workspace . --config shipgate.yaml --base $(git merge-base origin/main HEAD) --head HEAD:control.state=complete, all permissions true,release_decision.decision=passed,merge_verdict=mergeableRelease-readiness notes
docs/checks.mdSTABILITY.mdDecisions taken
--diff-from(in-repo) and a base are both given. The issue allowed either. Refusing with exit 2 would break every Action that sets bothdiff_fromanddiff_base: target. Reading the base copy keeps those workflows working and never reads the head._map_optional_tree_path) is how baselines and policy packs reach that same base-ref tree.base_notesnames what happened. (A base tree that holds a symlink cannot be archived at all, so that variant fails closed asarchive_failed, exactly as it does without--diff-from.)ci.mode: "stricter of base and head" rather than "base only". The mode isstrictif either side declares it, so a PR that adopts strict still takes effect in the same PR, and a head that keeps its base's mode behaves exactly as before. The mode decides only the exit code. Blockers are{critical} ∪ fail_on, and neither mode's defaultfail_onadds anything beyondcritical, so no verdict moves.ci.fail_onis floored for the exit code only (review cycle 2, C2-1). The first head flooredfail_onas a whole. That changed the governance benchmark'sci-advisory-from-strictcase: basemode: strict, fail_on: [critical, high], headmode: advisorywith nofail_on.decisionwent fromreview_requiredtoblocked, andmerge_verdictfromhuman_review_requiredtoblocked. The cause is that STABILITY's truth table definesblocker_severities = {critical} ∪ fail_policy.fail_on, so the PR's ownSHIP-VERIFY-POLICY-WEAKENED(high) blocked under the list it loosens. The second head dropped thefail_onfloor entirely, but that left the head'sfail_onsetting the exit code: in the Action, which passes--fail-ononly when set, dropping a severity still turned exit 20 into 0. Now the base's declaredci.fail_onreachesbuild_release_decisionasexit_fail_on_floorand is used only forexit_code_for_report. Blockers, the decision,merge_verdictandfail_policy.fail_onkeep the head's list, and the truth-table equation is unchanged. A PR that loosensfail_ontherefore fails CI (exit 20) until a human handles it, while its verdict stays human review.SHIP-VERIFY-POLICY-WEAKENEDis not exempt from the floor. The benchmark baseline is unchanged, andci/exit_policy.pyis unchanged.ci_modetorun_scan. Thefail_onfloor goes through a new optionalexit_fail_on_floorkeyword onrun_scan→_build_final_report→build_report→build_release_decision; it defaults toNone, soscan(which has no base) is unchanged.declared_cistill records the head's own block, soSHIP-VERIFY-POLICY-WEAKENEDstill compares the two declarations.base_notes. Neither case is PR-controlled.shipgate_state("the recorded baselines/waivers", already band-excluded) and a pack usespolicy, so no newtrust_root_classvalue appears. A configured input is never cleared by the instruction-structure or host-settings-narrowed shortcuts.base_statusvalue or check ID changes, so no schema regeneration was needed.Protected paths touched
none
Follow-up candidates