Skip to content

feat(launchpad): run_adjudication.py -- the adjudication CLI (#118 STEP 3) - #263

Merged
benmitchell11 merged 5 commits into
feat/review-agent-adjudication-verdictsfrom
feat/review-agent-adjudication-run
Aug 21, 2026
Merged

feat(launchpad): run_adjudication.py -- the adjudication CLI (#118 STEP 3)#263
benmitchell11 merged 5 commits into
feat/review-agent-adjudication-verdictsfrom
feat/review-agent-adjudication-run

Conversation

@serina-mcfall

@serina-mcfall serina-mcfall commented Aug 20, 2026

Copy link
Copy Markdown

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 needs verdicts.py.

Related issue

Refs #118

Issue type

Task


Agent provenance

Field Value
Harness / provider Claude Code
Model claude-opus-5
Session reference N/A - the harness exposes no shareable run id
Initiating human @serina-mcfall

Objective

Add launchpad/review-agent/run_adjudication.py and test_run_adjudication.py — the process that turns a merged findings document into an adjudicated one.

Impacted components

  • launchpad/review-agent/run_adjudication.py
  • launchpad/review-agent/test_run_adjudication.py

Approach and rejected alternatives

Two ways to obtain a verdict and no others: --judge stub (the default, returns UNPROVEN with 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 --model flag 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 notes field: plumbing a judge-supplied notes key 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, and adjudicator.md (#265) has been amended so it no longer mandates a channel this runner discards.

Verification

Command run:

cd launchpad/review-agent
python3 -m unittest test_run_adjudication
python3 -m unittest discover -s . -p 'test_*.py'

Raw output:

.........................
----------------------------------------------------------------------
Ran 25 tests in 0.183s

OK
................................................................................................................................................................................
----------------------------------------------------------------------
Ran 176 tests in 0.767s

OK

The blank-evidence guard, driven through the shipped --replay flag rather than an injected judge — the recording format is a mapping finding_id -> {...}, and the CONTROL row proves the lookup hits so the UNPROVEN rows are the guard firing and not a missed lookup:

=== --replay through the REAL process (correct recording format) ===
  CONTROL good evidence              exit=0 verdict=CONFIRMED lookup_hit=True validate=[]
  whitespace evidence                exit=0 verdict=UNPROVEN  lookup_hit=True validate=[]
  tab evidence                       exit=0 verdict=UNPROVEN  lookup_hit=True validate=[]
  non-string evidence                exit=0 verdict=UNPROVEN  lookup_hit=True validate=[]
  empty evidence                     exit=0 verdict=UNPROVEN  lookup_hit=True validate=[]
  • Tests or checks were run and the raw output is pasted above
  • The diff is confined to the scope of the linked issue
  • No secrets, keys, tokens or hostnames were added to tracked files

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. --replay is 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_untouched compares output against input after the call, so it is blind to aliasing: replace the copy.deepcopy with 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_reason handling is STEP 6's, not exercised here beyond confirming this step leaves severity equal to reported_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_evidence that is not a string with at least one non-whitespace character all yield UNPROVEN with 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 — and verdicts.validate accepted 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 --replay from 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_all call, no gh call and no HTTP call — imports are argparse, copy, json, sys, pathlib, typing, findings, verdicts and 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

  1. adjudication.notes is inert and that is now recorded, not resolved. The field is declared in ADJUDICATION.md and carried by verdicts.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.
  2. --replay is 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.
  3. review-final has 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.

…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>
@ciaran-slow

Copy link
Copy Markdown

Review pipeline — PR #263

Stages run: review-code, review-tests, review-a11y, review-adjudicate, review-final.
Plan read first: launchpad/plans/2026-08-13-issue-118-adjudication.md, STEP 3 at :442.
Diffed against this PR's own base (feat/review-agent-adjudication-verdicts, #261), so only STEP 3's own 750 lines are in scope.

Not applicable, declared: review-a11y — the plan's LEFT OUT puts accessibility out of scope for #118 and states why. check-ledger.sh — plan uses STEP N, not ### Task N:; no .superpowers/sdd/ ledger; the checker exits 1 on its vacuity guard, so I walked the step graph by hand.

Ran the suite myself in a clean worktree: python3 -m unittest test_run_adjudicationRan 19 tests, OK.


Findings

1. High — _run_judge_safely does not fail closed on whitespace-only evidence, and its docstring says it does

launchpad/review-agent/run_adjudication.py:188

if verdict not in verdicts.VERDICTS or not evidence:

The docstring at :166-172 promises: "fail closed to UNPROVEN on anything unusable — a raised exception, a non-dict return, an illegal/missing verdict, or empty verdict_evidence," quoting ADJUDICATION.md's "returns unusable output yields UNPROVEN with a reason." not " " is False, so whitespace is not empty by this test.

Probed end-to-end through adjudicate() on the chain tip, with a judge returning {"verdict": "CONFIRMED", "verdict_evidence": " \n "}:

verdict recorded : CONFIRMED
evidence repr    : '   \n  '
verdicts.validate: 0 violations

The verdict is published as CONFIRMED, the evidence is whitespace, and the contract check downstream reports the document clean.

Concrete failure: a judge whose response was truncated after a newline, or whose evidence field was stripped by a formatting step, produces exactly this. Per ADJUDICATION.md, a CONFIRMED finding at Blocker severity is what blocks a merge — so this publishes a merge-blocking verdict with no stated reason, in the one place the design's whole answer to unusable output is "fail closed to UNPROVEN". stub_judge never does this; --replay reads recorded verdicts straight from JSON at :301+ and would forward whatever a recording contains, and STEP 9 is the next planned step.

Fix: if verdict not in verdicts.VERDICTS or not str(evidence).strip():. One line here. The matching check in verdicts.py:268 has the same shape and wants the same fix — I reported the pair on #261; it is one defect at two sites, counted once there, repeated here because this is the site that carries the fail-closed promise.

2. High — the judge has no way to return a note, and adjudicator.md normatively tells it to

launchpad/review-agent/run_adjudication.py:79 and :258

The judge protocol at :79 is Judge = Callable[[dict, dict], dict], documented as "at least {"verdict": ..., "verdict_evidence": ...}". _run_judge_safely forwards only verdict and verdict_evidence (plus severity/severity_reason once STEP 6 lands). notes=[] at :258 is the only assignment to that field in the module — hardcoded empty on every run.

Meanwhile adjudicator.md:66 (#265) instructs the judge, normatively: "If you notice a genuinely new defect… record it in adjudication.notes and never in reports[].findings." The field is real — ADJUDICATION.md:137 declares it, verdicts.py:93 carries it.

Probed with a judge returning a notes key alongside a valid verdict:

adjudication.notes: []

Silently dropped.

Concrete failure: a judge follows exclusion 2 exactly as written and the observation is discarded. Its only remaining outlet is verdict_evidence — the field exclusion 1 exists to keep free of anything but independent evidence for that finding, and the field the plan's own OPEN item names as the escalate-only hole with no mechanical guard. So the instruction, followed faithfully, pushes free text into precisely the field that has no guard.

The contrast shows this is an oversight rather than a decision: severity_reason is plumbed carefully, including a generated default when the judge omits one, because verdicts.validate requires it. notes got the field and the docs but not the wire.

Fix: accept notes in the protocol and append what a judge returns — symmetric with the existing severity_reason handling — or say in adjudicator.md:66 that the channel is deferred. The first is better; the second is at least honest. Same defect as reported on #265, counted once across the pair, stated at both ends because the fix probably belongs here and the instruction belongs there.

3. Low — missing by:agent label

No labels; the body carries an Agent provenance block. launchpad/AGENTS.md §5 rule 3 requires by:agent.

gh pr edit 263 --repo launchpad-26/buzz --add-label by:agent


What I looked for and did not find

Several of these were hypotheses I expected to confirm and could not. They are recorded rather than dropped, because a negative result from a probe is a real result.

  • The input being mutated in place. adjudicate() writes six keys directly onto finding dicts (:233-240 in this diff), which is the classic shape for corrupting the input and then comparing the output against it — trivially satisfying every input-vs-output check the contract rests on. It does not happen: :222 is output_document = copy.deepcopy(input_document), and _run_judge_safely is handed the pristine input_document while the mutations land on the copy. The docstring's "Never mutates input_document" is accurate.
  • findings.validate not actually running before the judge. STEP 3's stated guarantee. :218-220 runs it and raises InputValidationError before the loop at :228, and the tests assert on the injected judge's own call count rather than on the exit code alone — which is the assertion that proves the refusal happens before the judge rather than after.
  • Valid-but-non-object JSON. main at :313 refuses [], "x", 42 before findings.validate ever sees them, with the reason given at :308-312: findings.validate assumes a dict and is not guaranteed to raise cleanly otherwise. This is reachable from untrusted stdin and is handled at the boundary. 4d05bfc83 on this branch is the commit that added it.
  • A gh call or an HTTP client on the stub path. None. No subprocess import, no urllib, no requests, no fetch_all call. The module reads stdin and writes stdout, which is what CONTAINMENT.md's prohibition on re-reading raw PR text requires.
  • Anchor pr mishandled. _location_description branches on anchor first and returns "the whole pull request (no file or line anchor)" without touching file or line, so a pr-anchored finding never renders None:None. Verified at 2be90c629 while checking feat(launchpad): adjudicator.md -- what the judge is told (#118 STEP 5) #265's citation of this exact function.
  • Tests that cannot fail. All 19. Fixtures and expectations are literals; the make_* helpers compose dicts from literals with keyword overrides and compute nothing. The pass-through assertions use json.dumps(..., sort_keys=True) comparison as the plan required, rather than field-by-field inspection.

On CI coverage

run_controls.py's CONTROLS list is hardcoded and names no test_*.py, so this suite runs in no CI job today. As on #261, that is a stated, deferred decision with a named owner — test_verdicts.py:9-13 assigns it to STEP 10's control suite (plan:741, [needs 4, 6, 7, 9]) — so I am not filing it as a finding. What nothing currently enforces is that STEP 10 actually wires them; that belongs in STEP 10's review.

Merge readiness

A reader of #118 STEP 3 would find the step's three named ways-to-lose-data all handled: byte-identical pass-through via deepcopy, no re-reading of raw PR text, and anchor pr treated as a normal shape rather than an error. The input-validation boundary is where the plan put it and is proven by call count, not by exit code. The --judge stub / --replay pair keeps model choice out of scope exactly as the plan's LEFT OUT requires.

The two findings are both about a channel that does not carry what its documentation says it carries — whitespace passing an emptiness guard, and a judge's note passing into nothing. Both are small fixes and neither is blocking. Both span this PR and a sibling, and I have cross-referenced rather than double-counted so the same defect does not read as four.

Merge order: this PR's base is #261's branch, so #261 must land first. The stack is #261#263#264#266#267.

What I could not check: whether a real judge produces the whitespace shape finding 1 describes. I demonstrated the guard does not catch it; how often a model emits it is STEP 9's measurement, not mine.

Independence and tools

Independent of the code under review: I did not write it. Not independent across pipeline stages — one context ran the reviewers, the adjudicator and the final pass. All findings are self-adjudicated; treat that as a limit on this report.

Tools actually held and used: Bash (git, git grep, git worktree, git show, gh, python3 for the probes), Read, Edit, Write. No Grep or Glob tool was available in this session.

Nothing found at Blocker.

CONFIRMED	High	launchpad/review-agent/run_adjudication.py:188	whitespace evidence defeats the fail-closed guard; same defect as verdicts.py:268 on #261
CONFIRMED	High	launchpad/review-agent/run_adjudication.py:258	judge-supplied notes silently dropped; adjudicator.md:66 instructs the judge to use them
CONFIRMED	Low	PR #263 (labels)	missing required by:agent label

Handed 3 findings, confirmed 3, refuted 0. Findings 1 and 2 are each one defect spanning two PRs, cross-referenced to #261 and #265 respectively rather than counted twice. Six further candidates were REFUTED by inspection or probe and are recorded above rather than dropped. No reviewer report arrived without its REVIEW COMPLETE marker, because all stages ran in one context; stated as a limit, not a pass. I did not author any of the code under review.

ADJUDICATION COMPLETE

REVIEW COMPLETE


Per launchpad/AGENTS.md §5 rule 1 — an agent drafts and raises, never approves or clears. This is a report, not an approval; the merge decision is @ciaran-slow's.

@ciaran-slow ciaran-slow left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. :188_run_judge_safely does not fail closed on whitespace evidence, and its docstring says it does. if verdict not in verdicts.VERDICTS or not evidence:not " " is False. Probed end-to-end: a judge returning {"verdict": "CONFIRMED", "verdict_evidence": " \n "} yields a CONFIRMED verdict with whitespace evidence, and verdicts.validate reports 0 violations. A CONFIRMED Blocker is what blocks a merge, so this can publish a merge-blocking verdict with no stated reason. Same defect as verdicts.py:268 on #261 — fix both.

  2. :258 — the judge has no way to return a note, and adjudicator.md:66 normatively tells it to. The judge protocol at :79 honours verdict, verdict_evidence, severity, severity_reason — no notes — and notes=[] is the only assignment in the module. Probed: a judge returning a notes key produces adjudication.notes: [], silently. Its only remaining outlet is verdict_evidence, the one field the plan's own OPEN item flags as having no mechanical guard. Either accept notes in the protocol (symmetric with how severity_reason is already plumbed, including its generated default), or amend adjudicator.md:66 to 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 ciaran-slow left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. :188_run_judge_safely does not fail closed on whitespace evidence, and its docstring says it does. if verdict not in verdicts.VERDICTS or not evidence:not " " is False. Probed end-to-end: a judge returning {"verdict": "CONFIRMED", "verdict_evidence": " \n "} yields a CONFIRMED verdict with whitespace evidence, and verdicts.validate reports 0 violations. A CONFIRMED Blocker is what blocks a merge, so this can publish a merge-blocking verdict with no stated reason. Same defect as verdicts.py:268 on #261 — fix both.

  2. :258 — the judge has no way to return a note, and adjudicator.md:66 normatively tells it to. The judge protocol at :79 honours verdict, verdict_evidence, severity, severity_reason — no notes — and notes=[] is the only assignment in the module. Probed: a judge returning a notes key produces adjudication.notes: [], silently. Its only remaining outlet is verdict_evidence, the one field the plan's own OPEN item flags as having no mechanical guard. Either accept notes in the protocol (symmetric with how severity_reason is already plumbed, including its generated default), or amend adjudicator.md:66 to 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.

@serina-mcfall

Copy link
Copy Markdown
Author

Independent adjudication of the change-request findings

Adjudicated 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 (4d05bfc83, pushed 2026-08-20T22:10Z, review 2026-08-21T03:57Z). Suite verified: Ran 19 tests ... OK in a clean worktree.

Findings

1. run_adjudication.py:188 — whitespace evidence defeats the fail-closed guard — CONFIRMED, High (unmoved).

Reproduced in-process, and then through the real CLI on a shipped flag, with no code injection at all:

$ python3 run_adjudication.py --replay <dir-with-one-recording>   # doc on stdin
exit: 0 | stderr:
PUBLISHED verdict: CONFIRMED | severity: Blocker | evidence repr: '   \n  '
verdicts.validate on published doc: []

make_replay_judge's return recorded forwards a recording's contents unfiltered, so the whitespace shape reaches :188 from an ordinary command line today. Exit 0, contract-clean, a CONFIRMED Blocker with no stated reason. That is a shorter path to harm than the review demonstrated.

One narrowing: the "docstring says the opposite" framing is over-stated. The docstring says it fails closed on "empty verdict_evidence", and " \n " is not empty — literally, it is accurate about what the guard does. What it promises and does not deliver is the first clause, "fail closed on anything unusable", and its own quotation of ADJUDICATION.md: "returns unusable output yields UNPROVEN with a reason." ADJUDICATION.md:28 makes the purpose explicit — "An UNPROVEN with no reason is indistinguishable from a stage that skipped the finding." Whitespace is indistinguishable from no reason. The defect stands on cleaner grounds.

No upstream guard: verdicts.py:268 is the identical truthiness test, which is why verdicts.validate returns [].

High rather than Blocker: nothing in the repo emits the whitespace shape — stub_judge never does, and --replay's intended producer is STEP 9's recordings, which do not exist. Someone must hand-craft the input. It becomes wrong the moment STEP 9 records a real judge, which is the next planned step. (The review body says "must be fixed before this merges" while the pipeline comment says "neither is blocking" — resolving toward the body's High, which does block.)

2. run_adjudication.py:258 — judge-supplied notes silently dropped — CONFIRMED, Medium (moved down from High).

All four ends of the drift verified independently, including the one on another branch:

end verified
adjudicator.md:66 (#265) "it does not become a finding: record it in adjudication.notes" — normative, line exact
ADJUDICATION.md:137 notes declared, counted in the load-bearing "Nine keys"
verdicts.py:93,105,119 carried by the dataclass, emitted, read back
run_adjudication.py:79,258 protocol honours four keys, notes absent; notes=[] is the only assignment

Probe: a judge returning a valid verdict plus notes=["new defect noticed: ..."] yields adjudication.notes: [], and the document validates clean against both contracts.

The "already documented" defence does not apply. The module docstring names its deferrals explicitly — severity, severity_reason, duplicate_of, downgrades, duplicate_groups. notes is the one hardcoded-empty field with no deferral statement anywhere. Confirmed independently on the chain tip: notes is absent from the plan's OPEN list entirely, appearing only in LEFT OUT as a positive requirement — the opposite of a deferral.

One piece of the review's reasoning is factually wrong. It argues the fix is "symmetric with how severity_reason is already plumbed, including its generated default", using that contrast to argue oversight rather than decision. At this ref there is no such plumbing:

run_adjudication.py:239:            finding["severity_reason"] = None

One assignment, hardcoded None — the same shape as notes=[], not a contrast to it. There is no generated default. The supporting argument collapses; the core claim survives on the documentation gap alone.

Medium because nothing wrong is published: notes carries no severity, enters no count, and new-defect-hunting is out of #118's scope. No code path in the repo or in flight can populate it. What survives today is that a maintainer reading ADJUDICATION.md:137 plus verdicts.py:93 reasonably concludes the channel works.

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 verdict_evidence, the one field with no guard. Cheapest discharge is one sentence adding notes to the STEP 6/7 deferral list in this module's docstring, which also puts the fix in the right file.

Spot-check of the six self-refutations — five checked, one does not fully hold

Because the findings were self-adjudicated, five of the six were probed rather than the two asked for.

refutation holds?
in-place input mutation Holdsinput unmutated: True; :222 is copy.deepcopy
validate not running before the judge Holds — injected counter: raised InputValidationError, judge calls = 0
valid-but-non-object JSON Holds[], 42, "x", { all exit 1 with distinct messages
anchor pr mishandled Holds — renders "the whole pull request (no file or line anchor)", never None:None
gh/HTTP on the stub path Holds — imports are argparse, copy, json, sys, pathlib, typing, findings, verdicts. Nothing else
tests that cannot fail — "all 19" Does not fully hold

Mutation-tested in a scratch copy (nine mutants, original restored and byte-verified). Six killed, three survived:

M1 skip deepcopy (alias the input)   -> Ran 19 tests | OK    <-- survived
M3 anchor pr branch removed          -> Ran 19 tests | OK    <-- survived
M5 stub_judge returns CONFIRMED      -> Ran 19 tests | OK    <-- survived
M2 M4 M6 M7 (validation, guards, duplicate_of, severity)  -> all FAILED, correctly

M1 is the sharp one, and it is exactly the shape the review went hunting for. test_pr_merge_base_head_and_containment_survive_untouched compares output_doc[key] against input_doc[key] after the call. Replace the deepcopy with an alias and output is input, so the comparison is self-referential and still passes. The review correctly refuted that the code mutates its input — the deepcopy is genuinely there — and then praised this very test. It would need a snapshot taken before the call. (In fairness: it does still catch lossy re-serialisation into a new object; it is blind only to aliasing.)

Scoring this honestly: the refutation rested on a tautology claim — "fixtures and expectations are literals; the make_* helpers compute nothing" — and that specific claim is true. Mutation survival is a different property, so the refutation is narrower than the sentence it was written as rather than wrong. Not filed as a finding — adjudicating is not reviewing — but flagged as the highest-value follow-up on this diff, since it is one line from fixed.

M8 (adding the strip() fix) and M9 (plumbing notes) both survived, which simply confirms findings 1 and 2 have no test coverage.

One root cause, three sites — and where each fix belongs

The root cause is a single idiom: testing emptiness with Python truthiness, which admits whitespace.

site branch role
verdicts.py:268 #261 (this PR's base) the contract check — the thing whose whole job is catching this
run_adjudication.py:188 #263 (here) the fail-closed producer guard
findings.py:239 pre-existing (#117) containment evidence — outside this diff

run_adjudication.py:516 cited on #261 does not exist on this branch — the file is 335 lines. That is the chain-tip line number for the same guard.

Verdict

state severity file:line summary
CONFIRMED High launchpad/review-agent/run_adjudication.py:188 Whitespace verdict_evidence passes the truthiness guard; reproduced via the shipped --replay flag publishing a CONFIRMED Blocker with no reason, contract-clean
CONFIRMED Medium launchpad/review-agent/run_adjudication.py:258 Judge-supplied notes silently dropped; adjudicator.md:66 mandates the channel and this module never documents it as deferred. High once #265 merges

Handed 2 (3 in the review block). 2 confirmed, 0 refuted, 1 resolved (the by:agent label). One severity moved down; one finding's framing narrowed while confirming it on stronger evidence. One of six self-refutations does not fully hold and is flagged as a follow-up rather than a finding.

🤖 Adjudicated by Claude Code (claude-opus-5) for @serina-mcfall. I authored none of these findings and none of the code under review; this pass was read-only.

serina-mcfall added a commit that referenced this pull request Aug 21, 2026
…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>
serina-mcfall added a commit that referenced this pull request Aug 21, 2026
…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>
serina-mcfall added a commit that referenced this pull request Aug 21, 2026
…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>
serina-mcfall added a commit that referenced this pull request Aug 21, 2026
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>
serina-mcfall added a commit that referenced this pull request Aug 21, 2026
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>
serina-mcfall added a commit that referenced this pull request Aug 21, 2026
…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 benmitchell11 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls verdicts.is_nonempty_str(evidence) (:214) instead of a bare truthiness test, and the docstring explains why ("Blank", not "empty") and explicitly ties it to verdicts.py so 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 a notes key 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.

@serina-mcfall

Copy link
Copy Markdown
Author

The requested change has landed — flagging for re-review

@ciaran-slow's CHANGES_REQUESTED on this PR named one inherited Blocker: non-list stages silently discarded, with the instruction to "fix on #264's branch and let it propagate." That has happened.

The fix: 57941045c"refuse a present-but-malformed stages manifest" — on feat/review-agent-adjudication-nonce (#264), merged forward through #266 (ec4dd4356) and #267 (965c2325b).

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 stages carrying a blocked pre-flight entry now exits 1 and is refused. 230 tests OK in a clean worktree.

Also resolved since that review: the cross-step notes drift @ciaran-slow flagged (four steps agreeing the field exists, none connecting them) — addressed on both ends, at 4a2bf04ba on #263 (documenting the deferral where the runner discards the key) and 05a960478 on #265 (adjudicator.md no longer mandating a channel the runner drops).

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 — _apply_severity_rerating's no-re-rating branch returned reported_severity without checking it, so a value that arrived illegal and that the judge agreed with was published untouched:

_apply_severity_rerating("x", "Info", "CONFIRMED", "Info", None, [])
  -> ('CONFIRMED', 'Info', None)

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 ADJUDICATION.md promises the guarantee holds on the effective severity. Fixed in c73a9ba73 on #266's branch — UNPROVEN, severity falls back to Blocker, reason names the refusal, nothing added to downgrades. Proved the new tests can fail: removing the guard on a scratch copy fails exactly the two new sub-cases while the legal-path control still passes.

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 check runs — an earlier failure and a later re-run that passes (e.g. 04:57:41 failure, 05:13:52 success on 965c2325b). Filtering the rollup on "any failure" reports these PRs as red when the latest run per check name is green. Worth taking the newest run, not any run.

🤖 Posted by Claude Code for @serina-mcfall. Claude-only pass on the severity fix — no independent cross-model check on that specific commit.

@benmitchell11
benmitchell11 merged commit 8455ddb into feat/review-agent-adjudication-verdicts Aug 21, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

by:agent Filed or authored by an AI agent, not a human

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants