feat(launchpad): run_adjudication.py -- the adjudication CLI (#118 STEP 3) - #263
Conversation
…EP 3) Reads one #117 merged document on stdin, adjudicates every finding with an injected judge callable (--judge stub default, --replay <dir> for STEP 9's future recordings), and prints one document on stdout. Input is validated with #117's own findings.validate before a single finding is adjudicated, pr/merge_base_sha/head_sha/containment pass through byte-identical, and anchor "pr" (file and line null) is adjudicated without raising. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
review-code found that run_adjudication.py only caught JSONDecodeError, so syntactically valid JSON whose top level isn't an object ([], "x", 42) parsed successfully and crashed downstream with an unhandled AttributeError/TypeError inside findings.validate, which assumes a dict. Reachable directly through this CLI's untrusted stdin -- the only caller that hands arbitrary input to findings.validate. Fixed by checking isinstance(input_document, dict) right after json.loads succeeds, refusing cleanly (matching the sibling refusal paths) before that assumption is ever exercised. Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Review pipeline — PR #263Stages run: Not applicable, declared: Ran the suite myself in a clean worktree: Findings1. High —
|
ciaran-slow
left a comment
There was a problem hiding this comment.
Comment review recording the pipeline result. Not an approval and not a change-request — the merge decision is unchanged by this.
Reviewed via the full pipeline — detail in my comment on this PR. Ran 19 tests ... OK in a clean worktree.
STEP 3's three named ways-to-lose-data are all handled properly: pass-through is a real copy.deepcopy so the input is never mutated (I checked, because writing six keys onto finding dicts is exactly the shape that corrupts the input and then compares the output against it), no raw PR text is re-read, and anchor pr is treated as a normal shape. The input-validation boundary is proven by the injected judge's own call count rather than by exit code, which is the assertion that actually shows the refusal precedes the judge.
Two High findings — both must be fixed before this merges.
-
:188—_run_judge_safelydoes not fail closed on whitespace evidence, and its docstring says it does.if verdict not in verdicts.VERDICTS or not evidence:—not " "isFalse. Probed end-to-end: a judge returning{"verdict": "CONFIRMED", "verdict_evidence": " \n "}yields a CONFIRMED verdict with whitespace evidence, andverdicts.validatereports 0 violations. A CONFIRMED Blocker is what blocks a merge, so this can publish a merge-blocking verdict with no stated reason. Same defect asverdicts.py:268on #261 — fix both. -
:258— the judge has no way to return a note, andadjudicator.md:66normatively tells it to. The judge protocol at:79honoursverdict,verdict_evidence,severity,severity_reason— nonotes— andnotes=[]is the only assignment in the module. Probed: a judge returning anoteskey producesadjudication.notes: [], silently. Its only remaining outlet isverdict_evidence, the one field the plan's own OPEN item flags as having no mechanical guard. Either acceptnotesin the protocol (symmetric with howseverity_reasonis already plumbed, including its generated default), or amendadjudicator.md:66to say the channel is deferred.
Nothing else. I checked six further candidates and refuted all six — in-place input mutation, validate-not-running-before-the-judge, valid-but-non-object JSON, a gh/HTTP call on the stub path, anchor pr mishandling, and tests that cannot fail. Recorded in my comment.
ciaran-slow
left a comment
There was a problem hiding this comment.
Change-request review. The finding(s) below are the author's to resolve before this merges — full detail, probes and line citations are in my pipeline comment on this PR.
Reviewed via the full pipeline — detail in my comment on this PR. Ran 19 tests ... OK in a clean worktree.
STEP 3's three named ways-to-lose-data are all handled properly: pass-through is a real copy.deepcopy so the input is never mutated (I checked, because writing six keys onto finding dicts is exactly the shape that corrupts the input and then compares the output against it), no raw PR text is re-read, and anchor pr is treated as a normal shape. The input-validation boundary is proven by the injected judge's own call count rather than by exit code, which is the assertion that actually shows the refusal precedes the judge.
Two High findings — both must be fixed before this merges.
-
:188—_run_judge_safelydoes not fail closed on whitespace evidence, and its docstring says it does.if verdict not in verdicts.VERDICTS or not evidence:—not " "isFalse. Probed end-to-end: a judge returning{"verdict": "CONFIRMED", "verdict_evidence": " \n "}yields a CONFIRMED verdict with whitespace evidence, andverdicts.validatereports 0 violations. A CONFIRMED Blocker is what blocks a merge, so this can publish a merge-blocking verdict with no stated reason. Same defect asverdicts.py:268on #261 — fix both. -
:258— the judge has no way to return a note, andadjudicator.md:66normatively tells it to. The judge protocol at:79honoursverdict,verdict_evidence,severity,severity_reason— nonotes— andnotes=[]is the only assignment in the module. Probed: a judge returning anoteskey producesadjudication.notes: [], silently. Its only remaining outlet isverdict_evidence, the one field the plan's own OPEN item flags as having no mechanical guard. Either acceptnotesin the protocol (symmetric with howseverity_reasonis already plumbed, including its generated default), or amendadjudicator.md:66to say the channel is deferred.
Nothing else. I checked six further candidates and refuted all six — in-place input mutation, validate-not-running-before-the-judge, valid-but-non-object JSON, a gh/HTTP call on the stub path, anchor pr mishandling, and tests that cannot fail. Recorded in my comment.
Independent adjudication of the change-request findingsAdjudicated in a fresh context, separate from the pipeline that produced the findings — which states its own limitation plainly: one context ran the reviewers, the adjudicator and the final pass, so its findings were self-adjudicated. Head unchanged since the review ( Findings1. Reproduced in-process, and then through the real CLI on a shipped flag, with no code injection at all:
One narrowing: the "docstring says the opposite" framing is over-stated. The docstring says it fails closed on "empty No upstream guard: High rather than Blocker: nothing in the repo emits the whitespace shape — 2. All four ends of the drift verified independently, including the one on another branch:
Probe: a judge returning a valid verdict plus The "already documented" defence does not apply. The module docstring names its deferrals explicitly — One piece of the review's reasoning is factually wrong. It argues the fix is "symmetric with how One assignment, hardcoded Medium because nothing wrong is published: Escalation trigger: this becomes High the moment #265 merges — from then on a normative instruction points a judge at a channel that discards its input, and the overflow lands in Spot-check of the six self-refutations — five checked, one does not fully holdBecause the findings were self-adjudicated, five of the six were probed rather than the two asked for.
Mutation-tested in a scratch copy (nine mutants, original restored and byte-verified). Six killed, three survived: M1 is the sharp one, and it is exactly the shape the review went hunting for. Scoring this honestly: the refutation rested on a tautology claim — "fixtures and expectations are literals; the M8 (adding the One root cause, three sites — and where each fix belongsThe root cause is a single idiom: testing emptiness with Python truthiness, which admits whitespace.
Verdict
Handed 2 (3 in the review block). 2 confirmed, 0 refuted, 1 resolved (the 🤖 Adjudicated by Claude Code ( |
…EP 2)
`validate` tested several required fields with Python truthiness, which is
the wrong check twice over: `not " "` is False, so whitespace satisfied
"non-empty", and there was no type check at all, so any truthy value passed.
Probed on the well-formed baseline, `verdict_evidence` set to each of
' ', '\n', '\t', '\xa0', 42, True, 0.5, ['x'] and {'a': 1} all yielded
zero violations; only '' was caught.
ADJUDICATION.md gives the reason the check exists: "An UNPROVEN with no
reason is indistinguishable from a stage that skipped the finding."
Whitespace is no reason, and `verdict_evidence: 42` is not actionable under
any reading -- so a CONFIRMED Blocker could carry a blank justification and
still validate clean. `verdict` one field above already does
`not isinstance(verdict, str)`, which is this module showing the strictness
it intended.
Four sites, two new helpers:
- `verdict_evidence` -- reported on #261 as High.
- `severity_reason` -- the same idiom in the same function, NOT reported by
any reviewer. Found while fixing its neighbour. A re-rating could be
justified by whitespace, which is the same defect wearing a different
field name.
- `findings_in` / `findings_out` / `reports[].findings_count` -- `bool`
subclasses `int`, so `isinstance(True, int)` is True. Worst at count 1,
where `True == 1 == len(findings)` keeps the equality comparison silent.
A non-int `findings_count` is now named rather than silently skipped,
because skipping it made the sum wrong and blamed `findings_in`.
- `duplicate_groups[].survivor` -- validated only indirectly, via a member
pointing back at it, so a group with an empty `duplicates` list never had
its survivor checked. Validator-only today (`_build_duplicate_groups`
gates on `len(candidate_ids) < 2`), but STEP 10's malformed-field controls
look for exactly this.
12 tests added, each failing before the change: 29 in test_verdicts (was
17), 150 across launchpad/review-agent, check_step2 and check_contract green.
Adjudicated on #261 by an independent pass over ciaran-slow's review; the
whitespace idiom's sibling site in `run_adjudication.py` belongs to #263 and
is not touched here.
Refs #118
Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…erdicts' into feat/review-agent-adjudication-run Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…EP 2) `is_nonempty_str` and `is_int` were private. STEP 3's `run_adjudication.py` enforces the same "non-empty" rule at its own fail-closed guard, and a second private copy of one contract rule is how the two drifted apart in the first place -- the validator accepted whitespace because the producer did, and neither had a shared definition to disagree with. Public so #263 can import the rule rather than re-implement it. No behaviour change: 150 tests across launchpad/review-agent still green. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…erdicts' into feat/review-agent-adjudication-run Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…es deferral (#118 STEP 3) Two findings adjudicated on #263. **`_run_judge_safely` did not fail closed on blank evidence.** The guard was `verdict not in verdicts.VERDICTS or not evidence` -- a truthiness test, so `not " "` is False and whitespace passed as usable output. Reproduced through the shipped `--replay` flag, no code injection: a recording carrying `verdict_evidence: " \n "` published a CONFIRMED verdict at exit 0, and `verdicts.validate` returned zero violations because the contract check used the same idiom. A CONFIRMED Blocker is what blocks a merge, so this could publish a merge-blocking verdict with no stated reason. The rule is now `verdicts.is_nonempty_str`, imported rather than re-implemented. That matters more than the strip() itself: this producer guard and the contract check drifted apart precisely because each had its own copy, and each admitted whitespace because the other did. The docstring said "empty verdict_evidence", which was literally accurate -- `" "` is not empty -- so it has been corrected to say what the guard enforces. The promise it actually broke was the clause before it, "fail closed on anything unusable", and ADJUDICATION.md's own words it quotes. **`adjudication.notes` was hardcoded empty with no deferral stated.** Every other hardcoded-empty field is named in the module docstring's STEP 6/7 deferral list; `notes` was the one that was not, while ADJUDICATION.md declares it and `verdicts.py` carries it -- so a reader had every reason to assume the channel worked. Now documented, at the docstring and at the assignment. Deliberately NOT resolved here: `adjudicator.md` (#265) normatively tells a judge to "record it in `adjudication.notes`", against a protocol that drops the key. Plumbing `notes` means designing how notes are collected and attributed, which is a STEP 6/7 decision, and amending #265 is #265's call. The tension is now stated in the code so it cannot merge past unnoticed rather than silently picked. 6 tests added, each failing before the change. One of them originally passed for the WRONG reason and was fixed: the replay recording format is a mapping `finding_id -> {...}`, not a flat record, so the first version missed the lookup entirely and asserted UNPROVEN against "no recorded judge output". It now carries a control proving the lookup HITS and returns CONFIRMED on good evidence, so the UNPROVEN in the blank cases is the guard firing. 25 tests in test_run_adjudication (was 19), 176 across launchpad/review-agent. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…STEP 4) Adjudicated on #264 as the root cause inherited unchanged by #266 and #267. A `stages` value that was present but not a list was treated as absent at both sites that read it: `_check_not_already_adjudicated` returned early, and the manifest builder substituted `[]`. Two failures from one shape defect, reproduced through the real CLI: stages = [{"name":"adjudication",...}] -> exit 1, correctly refused stages = {"0":{"name":"adjudication",...}} -> exit 0, guard bypassed stages = {"p":{"name":"preflight","status":"blocked",...}} -> exit 0, entry GONE, "complete" stages = 42 / "adjudication" / True -> exit 0, dropped The second is the expensive one. A `blocked` pre-flight -- #116's fork-PR-secrets-withheld case -- disappeared and the document published as a clean, complete review, because #119 only banners a non-`complete` status. That is #118's fifth criterion failing through a shape defect no verdict-side check looks at: `grep -n stages findings.py verdicts.py` returns nothing, so neither contract validator inspects this key at all. "#117 never emits that shape" is not available as a defence here. The `stages` manifest is explicitly an output #117 does NOT produce, so there is no upstream guarantee for this stage to inherit. Fix: one `_input_stages` reader, raising `StagesShapeError`, used by BOTH sites. One function rather than two corrected inline checks on purpose -- the two readers each had their own `isinstance(..., list)` test and each treated malformed as absent, which is how one defect became two independent failures. The same lesson as `verdicts.is_nonempty_str` on #261/#263: a second copy of a rule is a second chance to disagree with it. The comment claiming `_check_not_already_adjudicated` "already guarantees" no input entry is named `adjudication` is now actually true, and says why. Absent stays legal, and an explicit null reads as absent -- #117 emits no `stages` key at all, so a fix that refused absence would break every real document. Both are tested as controls, as is a well-formed `blocked` pre-flight surviving in order. Also closes the Low at the same site: a `stages` entry that is not an object, or whose `name` is not a string, is refused in the same change. 11 tests added, each failing before the change, including the refusal driven through the real process. 57 tests in test_run_adjudication (was 46 after merging #263), 207 across launchpad/review-agent. #266 and #267 carry these two guard sites byte-identically, only line-shifted. Fixing here rather than there so one change propagates up the chain instead of becoming three copies that can diverge. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Brings in #264's `StagesShapeError` / `_input_stages` fix and #261/#263's `verdicts.is_nonempty_str` guard. This PR's inherited Blocker is now closed by propagation rather than by a second copy of the guard, which is the point -- two divergent copies of one check is worse than the bug. Verified on this branch through the real CLI after the merge: an `adjudication` entry inside an object container exits 1 with no document (was exit 0), a `blocked` preflight in an object container exits 1 rather than being silently discarded (was published as `complete`), and the control -- a well-formed list carrying a `blocked` preflight -- still exits 0 with both entries in order. Conflicts were docstring and error-class additions only; both sides kept. 219 tests across launchpad/review-agent. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Brings #264's `StagesShapeError` / `_input_stages` and #261/#263's `verdicts.is_nonempty_str` up to the chain tip. Both of this PR's findings were homed on earlier branches -- the `stages` Blocker on #264 and the `notes` drift on #263 -- so this branch is cleared by propagation rather than by any change of its own, which is what the adjudication asked for. Clean merge, no conflicts. 230 tests across launchpad/review-agent. `notes` remains empty and is now documented as deferred at STEP 6/7, with the unresolved tension against `adjudicator.md` (#265) stated in the code. #265 should not merge ahead of that decision. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…118 STEP 5) Prohibition 2 told the judge to "record it in `adjudication.notes`". That is a normative instruction against a channel that silently drops its input: `run_adjudication.py` hardcodes `notes=[]` and its judge protocol reads only `verdict`, `verdict_evidence`, `severity` and `severity_reason`. Probed on the STEP 3 branch -- a judge returning a `notes` key produces `adjudication.notes: []`, and the document validates clean against both contracts, so nothing catches the loss. `ADJUDICATION.md` declares the field and `verdicts.py` carries it, which is what made the instruction look supported. Nothing between the declaration and the producer connects them. Adjudicated across #263 and #267 as one cross-step drift. Resolved on this side rather than by plumbing `notes` through the protocol, because collecting and attributing notes -- per-finding or per-document, deduped, ordered -- is STEP 6/7's design, and building that surface here would pre-empt a decision those steps own. The deferral is now recorded from both sides: this document and `run_adjudication.py`'s module docstring. Also states the consequence the old wording left implicit: with the channel deferred, a new observation has nowhere to go from this stage, so `verdict_evidence` must not be stretched to carry it. That field is the reason for the verdict on the finding the judge was given, and it is the one field with no structural guard -- exactly the wrong place for overflow. Doc-only. 121 tests across launchpad/review-agent green on this branch. Refs #118 Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
benmitchell11
left a comment
There was a problem hiding this comment.
Independent pass (not the pipeline that left the earlier reviews on this PR).
Checked out feat/review-agent-adjudication-run and ran the suite myself: Ran 176 tests ... OK in a clean worktree. Also re-ran the specific probe from the standing review rather than taking its word for it.
Both High findings from the earlier review are fixed on this branch's current tip (4a2bf04ba):
_run_judge_safely(run_adjudication.py:182-223) whitespace evidence. The guard now callsverdicts.is_nonempty_str(evidence)(:214) instead of a bare truthiness test, and the docstring explains why ("Blank", not "empty") and explicitly ties it toverdicts.pyso the two checks can't drift apart again. Confirmed by reading the code; this is the same fix as #261's, reused rather than re-implemented, which is the right call.- Silently-discarded
notes. Rather than plumbing anoteskey through the judge protocol, the fix documents the deferral explicitly at:61-72(module docstring) and:286-287(notes=[]with a comment naming the tension), and cross-references it. I'd call this a legitimate resolution, not a punt — it turns a silent gap into a stated one. Worth knowing: #265 (adjudicator.md) independently reaches the same conclusion and rewrites its own "record it in notes" instruction to match — so once both land the story is consistent, but right now the two PRs describe the resolution slightly differently and neither links the other's commit. Not a blocker, just something for whoever merges last to reconcile.
Re-checked the three claims from this PR's own Verification section myself rather than trusting them: copy.deepcopy is real (grep confirms, and the six new keys are the only writes to a finding dict), no gh/subprocess/network call anywhere in stub_judge or make_replay_judge's stub path, and InputValidationError is raised (adjudicate() at findings.validate call) before the judge loop begins — the loop that calls judge per finding is textually after the raise. Didn't find anything new beyond what's already flagged.
Not verified: no live model — stub_judge/replay-judge stub paths only, as intended for this step.
The requested change has landed — flagging for re-review@ciaran-slow's The fix: Independently confirmed, not assumed: @benmitchell11 re-ran the CLI probe on the chain tip rather than trusting the merge carried cleanly, and reported an object-shaped Also resolved since that review: the cross-step One further finding, fixed just now: @benmitchell11's pass also caught an out-of-ladder gap that both earlier review passes on this stack missed — Reproduced before fixing. This is a plan-conformance gap rather than only defence-in-depth: STEP 6's own done-when names this exact case, and Not claiming this clears the review — the merge decision is @ciaran-slow's. This is the evidence trail so re-review doesn't require re-deriving what changed. A note on reading CI on this stack: each commit here carries two 🤖 Posted by Claude Code for @serina-mcfall. Claude-only pass on the severity fix — no independent cross-model check on that specific commit. |
8455ddb
into
feat/review-agent-adjudication-verdicts
Summary
Implements STEP 3 of
launchpad/plans/2026-08-13-issue-118-adjudication.md: the adjudication CLI. Reads one #117 merged document on stdin, adjudicates every finding with an injected judge callable, and prints one adjudicated document on stdout. Stacked on the not-yet-merged STEP 2 branch (#261) since this step needsverdicts.py.Related issue
Refs #118
Issue type
Task
Agent provenance
Objective
Add
launchpad/review-agent/run_adjudication.pyandtest_run_adjudication.py— the process that turns a merged findings document into an adjudicated one.Impacted components
launchpad/review-agent/run_adjudication.pylaunchpad/review-agent/test_run_adjudication.pyApproach and rejected alternatives
Two ways to obtain a verdict and no others:
--judge stub(the default, returnsUNPROVENwith a stated reason) and--replay <dir>for STEP 9's future recordings. Neither lets a caller name a model, which is what keeps "choosing the model" out of this step's scope.Rejected: letting the judge be selected by a
--modelflag or an env var. That would put model choice in STEP 3 when #118 assigns it elsewhere, and it would make this step untestable without a live provider. The stub exists so the harness is demonstrable end to end before a single adjudication prompt is written (STEP 5).Rejected on the
notesfield: plumbing a judge-suppliednoteskey through the protocol. Collecting and attributing notes — per-finding or per-document, deduped, ordered — is STEP 6/7's design, and building that surface here would pre-empt a decision those steps own. Instead the deferral is now stated explicitly in the module docstring and at the assignment, andadjudicator.md(#265) has been amended so it no longer mandates a channel this runner discards.Verification
Command run:
Raw output:
The blank-evidence guard, driven through the shipped
--replayflag rather than an injected judge — the recording format is a mappingfinding_id -> {...}, and the CONTROL row proves the lookup hits so theUNPROVENrows are the guard firing and not a missed lookup:Not verified
No live model was called. Every verdict in every test comes from the stub or from a hand-written injected judge, so nothing here shows how a real adjudicator behaves — that is STEP 9's recorded before/after pairs, which do not exist yet.
--replayis therefore exercised only against recordings written by the tests themselves; the format is documented as provisional and STEP 9 may change it.The pass-through guarantee is asserted by a test that cannot fully prove it.
test_pr_merge_base_head_and_containment_survive_untouchedcompares output against input after the call, so it is blind to aliasing: replace thecopy.deepcopywith an alias and the test still passes. Found by mutation-testing this suite; filed as #284 rather than fixed here.The judge protocol's
severity/severity_reasonhandling is STEP 6's, not exercised here beyond confirming this step leavesseverityequal toreported_severity.Security implications
The judge callable is the trust boundary, and this step's posture is fail-closed at it: a raised exception, a non-dict return, an illegal verdict, or
verdict_evidencethat is not a string with at least one non-whitespace character all yieldUNPROVENwith a stated reason rather than propagating.That last clause was a real defect fixed on this branch. The guard used a truthiness test, so
" "passed as usable output — andverdicts.validateaccepted it too, because the contract check used the same idiom. A CONFIRMED Blocker is what blocks a merge, so a merge-blocking verdict could be published with no stated reason, reachable through--replayfrom an ordinary command line. Both ends now share one predicate,verdicts.is_nonempty_str, imported rather than re-implemented: they drifted apart precisely because each had its own copy.This module makes no
fetch_allcall, noghcall and no HTTP call — imports areargparse, copy, json, sys, pathlib, typing, findings, verdictsand nothing else. CONTAINMENT.md's prohibition on re-reading raw PR text holds by construction: the only input is the document already on stdin.Escalations
adjudication.notesis inert and that is now recorded, not resolved. The field is declared inADJUDICATION.mdand carried byverdicts.py, but this runner hardcodes it empty and its protocol does not read it. feat(launchpad): adjudicator.md -- what the judge is told (#118 STEP 5) #265 has been amended to stop instructing a judge to use it. The actual plumbing decision belongs to STEP 6/7 and is not made here.--replayis a real, reachable code path with no real producer. STEP 9's recordings do not exist, so the only inputs it has ever seen are test-authored. The blank-evidence defect above arrived through exactly this path, which is a reason to treat it as untrusted input rather than a stub.review-finalhas not been run for this branch. The plan's own GATES section schedules it at STEP 12, the end of the full 12-step chain, so this PR carries per-step review only.🤖 Drafted by Claude Code (
claude-opus-5) for @serina-mcfall.