Repository navigation
fix(decision): derive the exit code and the merge verdict from the one decision engine - #1093
Conversation
…e decision engine The exit code dropped every suppressed finding before matching fail_on, while build_release_decision keeps a suppressed mandatory current-surface control as a non-waivable blocker. Advisory mode with fail_on therefore exited 0 on a report whose decision was blocked by a blocker in that very fail_on (support_refund_agent with three checks.ignore entries). exit_code_for_report now takes the blockers the engine computed and exits 20 when one has a severity in fail_on, in either mode. Strict exit codes cannot move: a non-empty blockers list is a blocked decision, which strict mode already fails. validate_semantic_consistency refuses a report whose blocker in fail_on would not fail CI. The verify-run outcome, the verification receipt and the safety qualification script read the decision-to-merge-verdict projection from schemas/verifier.py instead of holding copies of its table; a test refuses a new dict-literal copy under src/ or scripts/. Closes #1029
…ill fails CI The verify path reads the same blockers: report.json, verifier.json and the process exit all report exit 20 for an advisory fail_on gate whose only critical blocker is a suppressed mandatory control.
🤖 Engineering review — implementer self-reviewI read 1. Can any combination still produce
|
|
🤖 Engineering review — independent review, cycle 1 Reviewed head: What I checked
On the open questions
|
Summary
Closes #1029.
Exit code.
exit_code_for_reportdropped every suppressed finding before matchingfail_on.build_release_decisionkeeps a suppressed mandatory current-surface control as a blocker that no suppression can waive. So in advisory mode withfail_on,checks.ignoreonsamples/support_refund_agentgaveDecision: blocked,would_fail_ci=falseand exit0. Without the ignores, the same config exits20. The same thing happened throughverifyand the Action: a PR that adds the ignores turned its own gate green.ci/exit_policy.py:exit_code_for_reporttakes theblockersthatbuild_release_decisioncomputed, as a required keyword. It exits20when any blocker's severity is in the resolvedfail_on, in either mode. That list includesverify's baseci.fail_onfloor. The rule reads the engine's blockers and does not restate which suppressions count. The stale comment ("strict mode intentionally passes when every critical finding has an explicit suppression reason") is replaced.blockers[]is ablockeddecision, and strict mode already exits20on one.core/semantic_consistency.py: refuses to write a report whose blocker infail_on(or strictblockeddecision) haswould_fail_ci=false. This sits next to the existing strict-insufficient_evidenceinvariant, so a future exit rule that stops reading the decision fails closed and cannot ship a green gate.Merge verdict. The decision-to-merge-verdict table in
schemas/verifier.py(_DECISION_TO_VERDICT/map_merge_verdict/merge_verdict_for) had copies in three places:schemas/verify_run.pyVerifyRunOutcome._control_projects_outcomenow callsmerge_verdict_forandapplicability_for. The applicability table it held was a copy ofapplicability_for.schemas/verification_identity.pyVerificationReceiptcallsmap_merge_verdict. The import sits inside the validator becauseschemas.verifierimports this module (CONTENT_ID_PATTERN).scripts/run_safety_qualification.pyEXPECTED_MERGE_VERDICTis now derived frommap_merge_verdict(same name, since tests import it).Not annotated with
MergeVerdict(a deliberate deviation from the issue's proposed fix):VerifyRunOutcome.merge_verdict: strchanges the SHA-frozendocs/verify-run-schema.v5.json. I tried it:generate_schemas.py --checkadds anenum, andFROZEN_CONTROL_SCHEMASpins that file. A narrowed field needs a successor schema, so the field staysstr. The validator enforces the projection.VerificationReceipt.merge_verdictis already aLiteralidentical toMergeVerdict, members and order alike. It cannot importMergeVerdictat class definition (import cycle). A new test pins it toMergeVerdictand itsdecisionliteral toReleaseDecisionStatus. Published receipt v1 schema bytes are unchanged.Docs.
STABILITY.md: the truth-table preface and the "Why exit code 20 depends on--baseline-mode" paragraph (STABILITY.md:4716 when the issue was filed) said suppressed findings are excluded. They now name the mandatory-control exception, and the table gains its row. A new paragraph states that the exit code reads the decision's blockers. The verify: read an in-repo --diff-from and ci.mode from the base, never from the PR head #1024 floor paragraph is corrected to match.CHANGELOG.md:## Unreleasedentry.Type
Authorship
Acceptance criteria
support_refund_agentwithchecks.ignoreon its mandatory controls, advisory mode,fail_on: [critical]:fail_policy.exit_codeand the process exit are 20.tests/test_scan.py::test_checks_ignore_cannot_turn_an_advisory_fail_on_gate_green[ignored0-fail_on0-20]uses the issue's three ignores, through the CLI (CliRunner). It asserts process exit20,fail_policy.exit_code == 20,would_fail_ci is Trueand decisionblocked. It also asserts that the two waivable controls left the blockers andSHIP-ACTION-FINANCIAL-WRITE-CONTROL-MISSINGstayed. Control rows: without the ignores the exit is20; with the ignores and nofail_onit is0.verify:tests/test_verify_pr_controlled_inputs.py::test_ignoring_a_control_no_suppression_can_waive_still_fails_ci. A PR ignores a refund tool'sSHIP-POLICY-APPROVAL-MISSINGandSHIP-ACTION-DESTRUCTIVE-ROLLBACK-MISSING. The test asserts thatreport.jsonfail_policy,verifier.jsonrelease_decision.fail_policyand the process exit all agree on20.tests/test_release_decision.py::test_advisory_fail_on_fails_on_a_blocker_no_suppression_can_waive(6 rows) andtest_the_base_fail_on_floor_reads_the_same_blockers.origin/main): the CLI test fails with exit0vs20, the verify test with{'exit_code': 0, 'would_fail_ci': False}, and the unit rows withassert 0 == 20.blockers=to the now-required keyword, and they pass.tests/test_release_decision.py::test_strict_exit_codes_are_unchangedadds 13 strict rows: suppressed mandatory, critical and high, matched with new-findings-only; suppressed waivable, with and withoutfail_on; matched and unmatched criticals;fail_on: [high]; policy blockers matched and unmatched. The rows go throughbuild_release_decisiononly, and all 13 also pass againstorigin/main's source.schemas/verifier.py(unit test or grep guard).tests/test_verdict_contract.py::test_no_dict_literal_outside_the_verifier_maps_a_decision_to_a_merge_verdictis an AST guard oversrc/agents_shipgate/andscripts/. It flags a dict literal with a decision-status key whose value is a merge verdict, or a tuple or list holding one (the receipt's("mergeable", True)shape). It also asserts it still finds the authority itself, so it cannot go blind, andtest_the_copy_guard_recognises_both_shapes_of_copycovers its detector. Againstorigin/mainit reportsverify_run.py:221,verification_identity.py:363andrun_safety_qualification.py:86.harness/adoption/is excluded on purpose, with the reason in the test: its scorer is an independent oracle that never reads the agents-shipgate schema.test_verdict_contractruns every decision status through all three validators.test_every_decision_status_runs_through_every_validator, 4 statuses × 3 validators (VerifierArtifact,VerifyRunOutcome,VerificationReceipt). Each case acceptsmap_merge_verdict(status)and refuses every otherMergeVerdictwith that validator's projection error.test_a_changed_projection_moves_every_validator_with_itstages the issue's scenario:insufficient_evidencere-mapped tohuman_review_requiredin_DECISION_TO_VERDICT. Againstorigin/main, theverify-run.jsonandverification-receipt.jsoncases fail.Verification
Branch rebased onto
origin/mainat #1088 (4e1f047438, pydantic 2.14.1). Everything below ran on the rebased tree, in a scratch venv built from thatconstraints/dev.txtwith--require-hashes, plusconstraints/build-backend.txt,pip install -e . --no-depsandpip check.agents_shipgate.__file__resolved to this worktree. The suite also gave the same result on the pre-rebase base under pydantic 2.13.5.python -m pytest -n auto -m "not perf" --ignore=tests/test_adapter_static_only.pytest_check_unmodelled_host_config_keys.py::…[local_settings_enabled_plugins-worktree|git_range]: this machine's global~/.config/git/ignoreholds**/.claude/settings.local.json, so the fixture'sgit commithas nothing to commit. This PR does not touch that file.python -m pytest tests/test_adapter_static_only.pypython -m pytest tests/test_latency_budget.py -m perfpython -m pytest tests/test_distribution_surface_parity.py tests/test_install_hooks.py tests/test_cold_start_replay.py tests/test_semantic_cold_start.py tests/test_cold_reader_order.pypython -m ruff check .python scripts/regenerate_goldens.py --checkpython scripts/generate_schemas.py --checkSample goldens did not move: no sample combines a suppressed mandatory control with
fail_on, so noreport.jsoncontent changed.End-to-end before/after, run with
./shipgate:origin/mainscan,support_refund_agent, advisory,fail_on: [critical], 3 ignoreswould_fail_ci=falsewould_fail_ci=truefail_onverify --base HEAD~1, PR adds the 3 ignoresexit_code: 0exit_code: 20Out of scope
docs/checks.md:7,docs/integrations.md:152anddocs/quickstart.md:1113. Strict mode already contradicted them before this PR.core/decision: architecture: move the decision engine to core/decision; enforce import layers #1053.Release-readiness notes
docs/checks.md(none changed)STABILITY.md(no schema change; exit-code behavior documented)🤖 Generated with Claude Code