From 0e49e3b71eabce190bf2d777f87a673806b8863b Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:27:21 -0400 Subject: [PATCH] fix(rank): reject an unknown veto verdict instead of shortlisting the job (#640) * fix(rank): reject an unknown veto verdict instead of shortlisting the job Step 5's veto is an exact match on "FAIL", so a scoring agent's "Fail" or any other off-contract location_verdict/language_gate value was stored as given and a deal-breaker job reached the shortlist. Report it as an errors row and leave the entry unwritten, as an unknown status already is. Co-Authored-By: Claude Opus 5.5 * changelog: place the #640 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Mads Lorentzen --- CHANGELOG.md | 2 ++ tests/test_rank_state.py | 11 +++++++++++ tools/rank_state.py | 13 +++++++++++++ 3 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4c7968..45cbd1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -432,6 +432,8 @@ per-file diff commands. - **`rank_state.py apply` rounds weighted scores exactly** (#588) - decimal half-points such as 31/55/51/96 now produce 60/Good Fit instead of 59/Moderate Fit from binary floating-point drift. +- **`rank_state.py apply` rejects unknown gate verdicts** (#640) - invalid `location_verdict` and `language_gate` values now return an error without shortlisting or writing the entry. + ### Security - **CI no longer leaves the job token in `.git/config` or expands a PR-controlled name into a diff --git a/tests/test_rank_state.py b/tests/test_rank_state.py index 08b296d8..c98b67ae 100644 --- a/tests/test_rank_state.py +++ b/tests/test_rank_state.py @@ -859,6 +859,17 @@ class Unreachable(RankStateCase): self.assertEqual([r["key"] for r in out["ranked"]], ["b"], "valid results in the same batch still apply") self.assertEqual(self.read_state()["a"]["status"], "new") + def test_an_unknown_gate_verdict_is_an_error_not_a_shortlisted_job(self): + scores = {dimension: 90 for dimension in ("technical", "experience", "behavioral", "career")} + for field, value in (("location_verdict", "Fail"), ("language_gate", "fail"), ("location_verdict", "REJECT")): + with self.subTest(field=field, value=value): + self.write_state({"a": entry()}) + result = {"key": "a", "status": "scored", "scores": scores, field: value} + out = self.run_tool("apply", "--results", self.results([result]), expect=1) + self.assertEqual(out["ranked"], [], "a deal-breaker spelled differently must not reach the shortlist") + self.assertIn(f"unknown {field} {value!r}", out["errors"][0]["error"]) + self.assertEqual(self.read_state()["a"]["status"], "new", "a rejected result never half-writes an entry") + if __name__ == "__main__": unittest.main() diff --git a/tools/rank_state.py b/tools/rank_state.py index 47b60527..8929a348 100644 --- a/tools/rank_state.py +++ b/tools/rank_state.py @@ -65,6 +65,10 @@ ISO = re.compile(r"^\d{4}-\d{2}-\d{2}$") # names exactly these, so the spec and the tool cannot drift apart. AGENT_STATUSES = ("scored", "expired", "unreachable") +# The values the two veto gates may take (rank.md Step 2). Step 5's veto is an +# exact match on "FAIL", so any other spelling would shortlist a deal-breaker. +GATE_VERDICTS = ("PASS", "FAIL", "FLAG") + # A posting that could not be fetched is `unreachable`, not `expired`, and is # retried on later runs (#534). It gives up to `expired` only after this many # failed attempts with at least this long between the first and the last, so a @@ -395,6 +399,15 @@ def cmd_apply(args) -> int: except ValueError as exc: errors.append({"key": key, "error": str(exc)}) continue + bad_gate = next( + (f for f in ("location_verdict", "language_gate") if result.get(f) and result[f] not in GATE_VERDICTS), + None, + ) + if bad_gate: + errors.append( + {"key": key, "error": f"unknown {bad_gate} {result[bad_gate]!r}; expected one of {', '.join(GATE_VERDICTS)}"} + ) + continue # A missing deadline from the fresh fetch is not a correction to the # stored date. Scored keys are excluded from the later sweep, so apply